Refactor pmctl into new portmaster-start

This commit is contained in:
Patrick Pacher
2020-07-16 15:05:24 +02:00
parent 7b12384b63
commit 58dad190a1
21 changed files with 819 additions and 868 deletions

6
cmds/portmaster-start/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# binaries
portmaster-start
portmaster-start.exe
# test dir
test

74
cmds/portmaster-start/build Executable file
View File

@@ -0,0 +1,74 @@
#!/bin/bash
# get build data
if [[ "$BUILD_COMMIT" == "" ]]; then
BUILD_COMMIT=$(git describe --all --long --abbrev=99 --dirty 2>/dev/null)
fi
if [[ "$BUILD_USER" == "" ]]; then
BUILD_USER=$(id -un)
fi
if [[ "$BUILD_HOST" == "" ]]; then
BUILD_HOST=$(hostname -f)
fi
if [[ "$BUILD_DATE" == "" ]]; then
BUILD_DATE=$(date +%d.%m.%Y)
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
BUILD_SOURCE=$(git remote -v | grep origin | cut -f2 | cut -d" " -f1 | head -n 1)
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
BUILD_SOURCE=$(git remote -v | cut -f2 | cut -d" " -f1 | head -n 1)
fi
BUILD_BUILDOPTIONS=$(echo $* | sed "s/ /§/g")
# check
if [[ "$BUILD_COMMIT" == "" ]]; then
echo "could not automatically determine BUILD_COMMIT, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_USER" == "" ]]; then
echo "could not automatically determine BUILD_USER, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_HOST" == "" ]]; then
echo "could not automatically determine BUILD_HOST, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_DATE" == "" ]]; then
echo "could not automatically determine BUILD_DATE, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
echo "could not automatically determine BUILD_SOURCE, please supply manually as environment variable."
exit 1
fi
# build tools
EXTRA_LD_FLAGS=""
if [[ $GOOS == "windows" ]]; then
# checks
if [[ $CC_FOR_windows_amd64 == "" ]]; then
echo "ENV variable CC_FOR_windows_amd64 (c compiler) is not set. Please set it to the cross compiler you want to use for compiling for windows_amd64"
exit 1
fi
if [[ $CXX_FOR_windows_amd64 == "" ]]; then
echo "ENV variable CXX_FOR_windows_amd64 (c++ compiler) is not set. Please set it to the cross compiler you want to use for compiling for windows_amd64"
exit 1
fi
# compilers
export CC=$CC_FOR_windows_amd64
export CXX=$CXX_FOR_windows_amd64
# custom
export CGO_ENABLED=1
EXTRA_LD_FLAGS='-H windowsgui' # Hide console window by default (but we attach to parent console if available)
# generate resource.syso for windows metadata / icon
go generate
fi
echo "Please notice, that this build script includes metadata into the build."
echo "This information is useful for debugging and license compliance."
echo "Run the compiled binary with the -version flag to see the information included."
# build
BUILD_PATH="github.com/safing/portbase/info"
go build -ldflags "$EXTRA_LD_FLAGS -X ${BUILD_PATH}.commit=${BUILD_COMMIT} -X ${BUILD_PATH}.buildOptions=${BUILD_BUILDOPTIONS} -X ${BUILD_PATH}.buildUser=${BUILD_USER} -X ${BUILD_PATH}.buildHost=${BUILD_HOST} -X ${BUILD_PATH}.buildDate=${BUILD_DATE} -X ${BUILD_PATH}.buildSource=${BUILD_SOURCE}" $*

View File

@@ -0,0 +1,12 @@
// +build !windows
package main
import "os/exec"
func attachToParentConsole() (attached bool, err error) {
return true, nil
}
func hideWindow(cmd *exec.Cmd) {
}

View File

@@ -0,0 +1,150 @@
package main
// Parts of this file are FORKED
// from https://github.com/apenwarr/fixconsole/blob/35b2e7d921eb80a71a5f04f166ff0a1405bddf79/fixconsole_windows.go
// on 16.07.2019
// with Apache-2.0 license
// authored by https://github.com/apenwarr
// docs/sources:
// Stackoverflow Question: https://stackoverflow.com/questions/23743217/printing-output-to-a-command-window-when-golang-application-is-compiled-with-ld
// MS AttachConsole: https://docs.microsoft.com/en-us/windows/console/attachconsole
import (
"log"
"os"
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
const (
windowsAttachParentProcess = ^uintptr(0) // (DWORD)-1
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procAttachConsole = kernel32.NewProc("AttachConsole")
)
// Windows console output is a mess.
//
// If you compile as "-H windows", then if you launch your program without
// a console, Windows forcibly creates one to use as your stdin/stdout, which
// is silly for a GUI app, so we can't do that.
//
// If you compile as "-H windowsgui", then it doesn't create a console for
// your app... but also doesn't provide a working stdin/stdout/stderr even if
// you *did* launch from the console. However, you can use AttachConsole()
// to get a handle to your parent process's console, if any, and then
// os.NewFile() to turn that handle into a fd usable as stdout/stderr.
//
// However, then you have the problem that if you redirect stdout or stderr
// from the shell, you end up ignoring the redirection by forcing it to the
// console.
//
// To fix *that*, we have to detect whether there was a pre-existing stdout
// or not. We can check GetStdHandle(), which returns 0 for "should be
// console" and nonzero for "already pointing at a file."
//
// Be careful though! As soon as you run AttachConsole(), it resets *all*
// the GetStdHandle() handles to point them at the console instead, thus
// throwing away the original file redirects. So we have to GetStdHandle()
// *before* AttachConsole().
//
// For some reason, powershell redirections provide a valid file handle, but
// writing to that handle doesn't write to the file. I haven't found a way
// to work around that. (Windows 10.0.17763.379)
//
// Net result is as follows.
// Before:
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe broken works
// powershell broken broken
// WSL bash broken works
// After
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe works works
// powershell works broken
// WSL bash works works
//
// We don't seem to make anything worse, at least.
func attachToParentConsole() (attached bool, err error) {
// get std handles before we attempt to attach to parent console
stdin, _ := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
stdout, _ := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
stderr, _ := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
// attempt to attach to parent console
err = procAttachConsole.Find()
if err != nil {
return false, err
}
r1, _, _ := procAttachConsole.Call(windowsAttachParentProcess)
if r1 == 0 {
// possible errors:
// ERROR_ACCESS_DENIED: already attached to console
// ERROR_INVALID_HANDLE: process does not have console
// ERROR_INVALID_PARAMETER: process does not exist
return false, nil
}
// get std handles after we attached to console
var invalid syscall.Handle
con := invalid
if stdin == invalid {
stdin, _ = syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
}
if stdout == invalid {
stdout, _ = syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
con = stdout
}
if stderr == invalid {
stderr, _ = syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
con = stderr
}
// correct output mode
if con != invalid {
// Make sure the console is configured to convert
// \n to \r\n, like Go programs expect.
h := windows.Handle(con)
var st uint32
err := windows.GetConsoleMode(h, &st)
if err != nil {
log.Printf("failed to get console mode: %s\n", err)
} else {
err = windows.SetConsoleMode(h, st&^windows.DISABLE_NEWLINE_AUTO_RETURN)
if err != nil {
log.Printf("failed to set console mode: %s\n", err)
}
}
}
// fix std handles to correct values (ie. redirects)
if stdin != invalid {
os.Stdin = os.NewFile(uintptr(stdin), "stdin")
log.Printf("fixed os.Stdin after attaching to parent console\n")
}
if stdout != invalid {
os.Stdout = os.NewFile(uintptr(stdout), "stdout")
log.Printf("fixed os.Stdout after attaching to parent console\n")
}
if stderr != invalid {
os.Stderr = os.NewFile(uintptr(stderr), "stderr")
log.Printf("fixed os.Stderr after attaching to parent console\n")
}
log.Println("attached to parent console")
return true, nil
}
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}

View File

@@ -0,0 +1,182 @@
package main
// Based on the official Go examples from
// https://github.com/golang/sys/blob/master/windows/svc/example
// by The Go Authors.
// Original LICENSE (sha256sum: 2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067) can be found in vendor/pkg cache directory.
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
const exeSuffix = ".exe"
func init() {
rootCmd.AddCommand(installCmd)
installCmd.AddCommand(installService)
rootCmd.AddCommand(uninstallCmd)
uninstallCmd.AddCommand(uninstallService)
}
var installCmd = &cobra.Command{
Use: "install",
Short: "Install system integrations",
}
var uninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Uninstall system integrations",
}
var installService = &cobra.Command{
Use: "core-service",
Short: "Install Portmaster Core Windows Service",
RunE: installWindowsService,
}
var uninstallService = &cobra.Command{
Use: "core-service",
Short: "Uninstall Portmaster Core Windows Service",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// non-nil dummy to override db flag requirement
return nil
},
RunE: uninstallWindowsService,
}
func getAbsBinaryPath() (string, error) {
p, err := filepath.Abs(os.Args[0])
if err != nil {
return "", err
}
return p, nil
}
func getServiceExecCommand(exePath string, escape bool) []string {
return []string{
maybeEscape(exePath, escape),
"core-service",
"--data",
maybeEscape(dataRoot.Path, escape),
"--input-signals",
}
}
func maybeEscape(s string, escape bool) string {
if escape {
return windows.EscapeArg(s)
}
return s
}
func getServiceConfig(exePath string) mgr.Config {
return mgr.Config{
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
BinaryPathName: strings.Join(getServiceExecCommand(exePath, true), " "),
DisplayName: "Portmaster Core",
Description: "Portmaster Application Firewall - Core Service",
}
}
func getRecoveryActions() (recoveryActions []mgr.RecoveryAction, resetPeriod uint32) {
return []mgr.RecoveryAction{
{
Type: mgr.ServiceRestart, // one of NoAction, ComputerReboot, ServiceRestart or RunCommand
Delay: 1 * time.Minute, // the time to wait before performing the specified action
},
}, 86400
}
func installWindowsService(cmd *cobra.Command, args []string) error {
// get exe path
exePath, err := getAbsBinaryPath()
if err != nil {
return fmt.Errorf("failed to get exe path: %s", err)
}
// connect to Windows service manager
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %s", err)
}
defer m.Disconnect() //nolint:errcheck // TODO
// open service
created := false
s, err := m.OpenService(serviceName)
if err != nil {
// create service
cmd := getServiceExecCommand(exePath, false)
s, err = m.CreateService(serviceName, cmd[0], getServiceConfig(exePath), cmd[1:]...)
if err != nil {
return fmt.Errorf("failed to create service: %s", err)
}
defer s.Close()
created = true
} else {
// update service
err = s.UpdateConfig(getServiceConfig(exePath))
if err != nil {
return fmt.Errorf("failed to update service: %s", err)
}
defer s.Close()
}
// update recovery actions
err = s.SetRecoveryActions(getRecoveryActions())
if err != nil {
return fmt.Errorf("failed to update recovery actions: %s", err)
}
if created {
log.Printf("created service %s\n", serviceName)
} else {
log.Printf("updated service %s\n", serviceName)
}
return nil
}
func uninstallWindowsService(cmd *cobra.Command, args []string) error {
// connect to Windows service manager
m, err := mgr.Connect()
if err != nil {
return err
}
defer m.Disconnect() //nolint:errcheck // we don't care if we failed to disconnect from the service manager, we're quitting anyway.
// open service
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service %s is not installed", serviceName)
}
defer s.Close()
_, err = s.Control(svc.Stop)
if err != nil {
log.Printf("failed to stop service: %s\n", err)
}
// delete service
err = s.Delete()
if err != nil {
return fmt.Errorf("failed to delete service: %s", err)
}
log.Printf("uninstalled service %s\n", serviceName)
return nil
}

View File

@@ -0,0 +1,64 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strconv"
"strings"
processInfo "github.com/shirou/gopsutil/process"
)
func checkAndCreateInstanceLock(name string) (pid int32, err error) {
lockFilePath := filepath.Join(dataRoot.Path, fmt.Sprintf("%s-lock.pid", name))
// read current pid file
data, err := ioutil.ReadFile(lockFilePath)
if err != nil {
if os.IsNotExist(err) {
// create new lock
return 0, createInstanceLock(lockFilePath)
}
return 0, err
}
// file exists!
parsedPid, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil {
log.Printf("failed to parse existing lock pid file (ignoring): %s\n", err)
return 0, createInstanceLock(lockFilePath)
}
// check if process exists
p, err := processInfo.NewProcess(int32(parsedPid))
if err == nil {
return p.Pid, nil
}
// else create new lock
return 0, createInstanceLock(lockFilePath)
}
func createInstanceLock(lockFilePath string) error {
// check data root dir
err := dataRoot.Ensure()
if err != nil {
log.Printf("failed to check data root dir: %s\n", err)
}
// create lock file
err = ioutil.WriteFile(lockFilePath, []byte(fmt.Sprintf("%d", os.Getpid())), 0666)
if err != nil {
return err
}
return nil
}
func deleteInstanceLock(name string) error {
lockFilePath := filepath.Join(dataRoot.Path, fmt.Sprintf("%s-lock.pid", name))
return os.Remove(lockFilePath)
}

View File

@@ -0,0 +1,137 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"time"
"github.com/safing/portbase/container"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/formats/dsd"
"github.com/safing/portbase/info"
"github.com/spf13/cobra"
)
func initializeLogFile(logFilePath string, identifier string, version string) *os.File {
logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE, 0444)
if err != nil {
log.Printf("failed to create log file %s: %s\n", logFilePath, err)
return nil
}
// create header, so that the portmaster can view log files as a database
meta := record.Meta{}
meta.Update()
meta.SetAbsoluteExpiry(time.Now().Add(720 * time.Hour).Unix()) // one month
// manually marshal
// version
c := container.New([]byte{1})
// meta
metaSection, err := dsd.Dump(meta, dsd.JSON)
if err != nil {
log.Printf("failed to serialize header for log file %s: %s\n", logFilePath, err)
finalizeLogFile(logFile)
return nil
}
c.AppendAsBlock(metaSection)
// log file data type (string) and newline for better manual viewing
c.Append([]byte("S\n"))
c.Append([]byte(fmt.Sprintf("executing %s version %s on %s %s\n", identifier, version, runtime.GOOS, runtime.GOARCH)))
_, err = logFile.Write(c.CompileData())
if err != nil {
log.Printf("failed to write header for log file %s: %s\n", logFilePath, err)
finalizeLogFile(logFile)
return nil
}
return logFile
}
func finalizeLogFile(logFile *os.File) {
logFilePath := logFile.Name()
err := logFile.Close()
if err != nil {
log.Printf("failed to close log file %s: %s\n", logFilePath, err)
}
// check file size
stat, err := os.Stat(logFilePath)
if err != nil {
return
}
// delete if file is smaller than
if stat.Size() >= 200 { // header + info is about 150 bytes
return
}
if err := os.Remove(logFilePath); err != nil {
log.Printf("failed to delete empty log file %s: %s\n", logFilePath, err)
}
}
func getLogFile(options *Options, version, ext string) *os.File {
// check logging dir
logFileBasePath := filepath.Join(logsRoot.Path, options.ShortIdentifier)
err := logsRoot.EnsureAbsPath(logFileBasePath)
if err != nil {
log.Printf("failed to check/create log file folder %s: %s\n", logFileBasePath, err)
}
// open log file
logFilePath := filepath.Join(logFileBasePath, fmt.Sprintf("%s%s", time.Now().UTC().Format("2006-01-02-15-04-05"), ext))
return initializeLogFile(logFilePath, options.Identifier, version)
}
func getPmStartLogFile(ext string) *os.File {
return getLogFile(&Options{
ShortIdentifier: "start",
Identifier: "start/portmaster-start",
}, info.Version(), ext)
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only
func logControlError(cErr error) {
// check if error present
if cErr == nil {
return
}
errorFile := getPmStartLogFile(".error.log")
if errorFile == nil {
return
}
defer errorFile.Close()
fmt.Fprintln(errorFile, cErr.Error())
}
//nolint:deadcode,unused // TODO
func logControlStack() {
fp := getPmStartLogFile(".stack.log")
if fp == nil {
return
}
defer fp.Close()
// write error and close
_ = pprof.Lookup("goroutine").WriteTo(fp, 2)
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only
func runAndLogControlError(wrappedFunc func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
err := wrappedFunc(cmd, args)
if err != nil {
logControlError(err)
}
return err
}
}

View File

@@ -0,0 +1,221 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/safing/portbase/dataroot"
"github.com/safing/portbase/info"
portlog "github.com/safing/portbase/log"
"github.com/safing/portbase/updater"
"github.com/safing/portbase/utils"
"github.com/spf13/cobra"
)
var (
dataDir string
maxRetries int
dataRoot *utils.DirStructure
logsRoot *utils.DirStructure
// create registry
registry = &updater.ResourceRegistry{
Name: "updates",
UpdateURLs: []string{
"https://updates.safing.io",
},
Beta: false,
DevMode: false,
Online: true, // is disabled later based on command
}
rootCmd = &cobra.Command{
Use: "portmaster-start",
Short: "Start Portmaster components",
PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
if err := configureDataRoot(); err != nil {
return err
}
if err := configureLogging(); err != nil {
return err
}
return nil
},
SilenceUsage: true,
}
)
func init() {
// Let cobra ignore if we are running as "GUI" or not
cobra.MousetrapHelpText = ""
flags := rootCmd.PersistentFlags()
{
flags.StringVar(&dataDir, "data", "", "Configures the data directory. Alternatively, this can also be set via the environment variable PORTMASTER_DATA.")
flags.IntVar(&maxRetries, "max-retries", 5, "Maximum number of retries when starting a Portmaster component")
flags.BoolVar(&stdinSignals, "input-signals", false, "Emulate signals using stdid.")
_ = rootCmd.MarkPersistentFlagDirname("data")
_ = flags.MarkHidden("input-signals")
}
}
func main() {
cobra.OnInitialize(initCobra)
// set meta info
info.Set("Portmaster Start", "0.3.5", "AGPLv3", false)
// for debugging
// log.Start()
// log.SetLogLevel(log.TraceLevel)
// go func() {
// time.Sleep(3 * time.Second)
// pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
// os.Exit(1)
// }()
// catch interrupt for clean shutdown
signalCh := make(chan os.Signal, 2)
signal.Notify(
signalCh,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
)
// start root command
go func() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
os.Exit(0)
}()
// for debugging windows service (no stdout/err)
// go func() {
// time.Sleep(10 * time.Second)
// // initiateShutdown(nil)
// // logControlStack()
// }()
// wait for signals
for sig := range signalCh {
if childIsRunning.IsSet() {
log.Printf("got %s signal (ignoring), waiting for child to exit...\n", sig)
continue
}
log.Printf("got %s signal, exiting... (not executing anything)\n", sig)
os.Exit(0)
}
}
func initCobra() {
// check if we are running in a console (try to attach to parent console if available)
var err error
runningInConsole, err = attachToParentConsole()
if err != nil {
log.Fatalf("failed to attach to parent console: %s\n", err)
}
// check if meta info is ok
err = info.CheckVersion()
if err != nil {
log.Fatalf("compile error: please compile using the provided build script")
}
// set up logging
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
log.SetPrefix("[control] ")
log.SetOutput(os.Stdout)
// not using portbase logger
portlog.SetLogLevel(portlog.CriticalLevel)
}
func configureDataRoot() error {
// The data directory is not
// check for environment variable
// PORTMASTER_DATA
if dataDir == "" {
dataDir = os.Getenv("PORTMASTER_DATA")
}
// check data dir
if dataDir == "" {
return errors.New("please set the data directory using --data=/path/to/data/dir")
}
// remove redundant escape characters and quotes
dataDir = strings.Trim(dataDir, `\"`)
// initialize dataroot
err := dataroot.Initialize(dataDir, 0755)
if err != nil {
return fmt.Errorf("failed to initialize data root: %s", err)
}
dataRoot = dataroot.Root()
// initialize registry
err = registry.Initialize(dataRoot.ChildDir("updates", 0755))
if err != nil {
return err
}
registry.AddIndex(updater.Index{
Path: "stable.json",
Stable: true,
Beta: false,
})
// TODO: enable loading beta versions
// registry.AddIndex(updater.Index{
// Path: "beta.json",
// Stable: false,
// Beta: true,
// })
updateRegistryIndex()
return nil
}
func configureLogging() error {
// set up logs root
logsRoot = dataRoot.ChildDir("logs", 0777)
err := logsRoot.Ensure()
if err != nil {
return fmt.Errorf("failed to initialize logs root: %s", err)
}
// warn about CTRL-C on windows
if runningInConsole && onWindows {
log.Println("WARNING: portmaster-start is marked as a GUI application in order to get rid of the console window.")
log.Println("WARNING: CTRL-C will immediately kill without clean shutdown.")
}
return nil
}
func updateRegistryIndex() {
err := registry.LoadIndexes(context.Background())
if err != nil {
log.Printf("WARNING: error loading indexes: %s\n", err)
}
err = registry.ScanStorage("")
if err != nil {
log.Printf("WARNING: error during storage scan: %s\n", err)
}
registry.SelectVersions()
}

102
cmds/portmaster-start/pack Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
baseDir="$( cd "$(dirname "$0")" && pwd )"
cd "$baseDir"
COL_OFF="\033[00m"
COL_BOLD="\033[01;01m"
COL_RED="\033[31m"
destDirPart1="../dist"
destDirPart2="start"
function check {
# output
output="portmaster-start"
# get version
version=$(grep "info.Set" main.go | cut -d'"' -f4)
# build versioned file name
filename="portmaster-start_v${version//./-}"
# platform
platform="${GOOS}_${GOARCH}"
if [[ $GOOS == "windows" ]]; then
filename="${filename}.exe"
output="${output}.exe"
fi
# build destination path
destPath=${destDirPart1}/${platform}/${destDirPart2}/$filename
# check if file exists
if [[ -f $destPath ]]; then
echo "[start] $platform $version already built"
else
echo -e "${COL_BOLD}[start] $platform $version${COL_OFF}"
fi
}
function build {
# output
output="portmaster-start"
# get version
version=$(grep "info.Set" main.go | cut -d'"' -f4)
# build versioned file name
filename="portmaster-start_v${version//./-}"
# platform
platform="${GOOS}_${GOARCH}"
if [[ $GOOS == "windows" ]]; then
filename="${filename}.exe"
output="${output}.exe"
fi
# build destination path
destPath=${destDirPart1}/${platform}/${destDirPart2}/$filename
# check if file exists
if [[ -f $destPath ]]; then
echo "[start] $platform already built in version $version, skipping..."
return
fi
# build
./build
if [[ $? -ne 0 ]]; then
echo -e "\n${COL_BOLD}[start] $platform: ${COL_RED}BUILD FAILED.${COL_OFF}"
exit 1
fi
mkdir -p $(dirname $destPath)
cp $output $destPath
echo -e "\n${COL_BOLD}[start] $platform: successfully built.${COL_OFF}"
}
function check_all {
GOOS=linux GOARCH=amd64 check
GOOS=windows GOARCH=amd64 check
GOOS=darwin GOARCH=amd64 check
}
function build_all {
GOOS=linux GOARCH=amd64 build
GOOS=windows GOARCH=amd64 build
GOOS=darwin GOARCH=amd64 build
}
case $1 in
"check" )
check_all
;;
"build" )
build_all
;;
* )
echo ""
echo "build list:"
echo ""
check_all
echo ""
read -p "press [Enter] to start building" x
echo ""
build_all
echo ""
echo "finished building."
echo ""
;;
esac

View File

@@ -0,0 +1,384 @@
package main
import (
"fmt"
"io"
"log"
"os"
"os/exec"
"path"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/tevino/abool"
)
const (
// RestartExitCode is the exit code that any service started by portmaster-start
// can return in order to trigger a restart after a clean shutdown.
RestartExitCode = 23
)
var (
runningInConsole bool
onWindows = runtime.GOOS == "windows"
stdinSignals bool
childIsRunning = abool.NewBool(false)
)
// Options for starting component
type Options struct {
Name string
Identifier string // component identifier
ShortIdentifier string // populated automatically
SuppressArgs bool // do not use any args
AllowDownload bool // allow download of component if it is not yet available
AllowHidingWindow bool // allow hiding the window of the subprocess
NoOutput bool // do not use stdout/err if logging to file is available (did not fail to open log file)
}
func init() {
registerComponent([]Options{
{
Name: "Portmaster Core",
Identifier: "core/portmaster-core",
AllowDownload: true,
AllowHidingWindow: true,
},
{
Name: "Portmaster App",
Identifier: "app/portmaster-app",
AllowDownload: false,
AllowHidingWindow: false,
},
{
Name: "Portmaster Notifier",
Identifier: "notifier/portmaster-notifier",
AllowDownload: false,
AllowHidingWindow: true,
},
})
}
func registerComponent(opts []Options) {
for idx := range opts {
opt := &opts[idx] // we need a copy
if opt.ShortIdentifier == "" {
opt.ShortIdentifier = path.Dir(opt.Identifier)
}
rootCmd.AddCommand(
&cobra.Command{
Use: opt.ShortIdentifier,
Short: "Run the " + opt.Name,
RunE: func(cmd *cobra.Command, args []string) error {
err := run(cmd, opt, args)
initiateShutdown(err)
return err
},
},
)
showCmd.AddCommand(
&cobra.Command{
Use: opt.ShortIdentifier,
Short: "Show command to execute the " + opt.Name,
RunE: func(cmd *cobra.Command, args []string) error {
return show(cmd, opt, args)
},
},
)
}
}
func getExecArgs(opts *Options, cmdArgs []string) []string {
if opts.SuppressArgs {
return nil
}
args := []string{"--data", dataDir}
if stdinSignals {
args = append(args, "-input-signals")
}
args = append(args, cmdArgs...)
return args
}
func run(cmd *cobra.Command, opts *Options, cmdArgs []string) (err error) {
// set download option
registry.Online = opts.AllowDownload
if isShutdown() {
return nil
}
// get original arguments
// additional parameters can be specified using -- --some-parameter
args := getExecArgs(opts, cmdArgs)
// check for duplicate instances
if opts.ShortIdentifier == "core" {
pid, _ := checkAndCreateInstanceLock(opts.ShortIdentifier)
if pid != 0 {
return fmt.Errorf("another instance of Portmaster Core is already running: PID %d", pid)
}
defer func() {
err := deleteInstanceLock(opts.ShortIdentifier)
if err != nil {
log.Printf("failed to delete instance lock: %s\n", err)
}
}()
}
// notify service after some time
go func() {
// assume that after 3 seconds service has finished starting
time.Sleep(3 * time.Second)
startupComplete <- struct{}{}
}()
// adapt identifier
if onWindows {
opts.Identifier += ".exe"
}
// setup logging
// init log file
logFile := getPmStartLogFile(".log")
if logFile != nil {
// don't close logFile, will be closed by system
if opts.NoOutput {
log.Println("disabling log output to stdout... bye!")
log.SetOutput(logFile)
} else {
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
}
}
return runAndRestart(opts, args)
}
func runAndRestart(opts *Options, args []string) error {
tries := 0
for {
tryAgain, err := execute(opts, args)
if err != nil {
log.Printf("%s failed with: %s\n", opts.Identifier, err)
tries++
if tries >= maxRetries {
log.Printf("encountered %d consecutive errors, giving up ...", tries)
return err
}
} else {
tries = 0
log.Printf("%s exited without error", opts.Identifier)
}
if !tryAgain {
return err
}
// if a restart was requested `tries` is set to 0 so
// this becomes a no-op.
time.Sleep(time.Duration(2*tries) * time.Second)
if tries >= 2 || err == nil {
// if we are constantly failing or a restart was requested
// try to update the resources.
log.Printf("updating registry index")
updateRegistryIndex()
}
}
}
func fixExecPerm(path string) error {
if onWindows {
return nil
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.Mode() == 0755 {
return nil
}
if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", path, err)
}
return nil
}
func copyLogs(opts *Options, consoleSink io.Writer, version, ext string, logSource io.Reader, notifier chan<- struct{}) {
defer func() { notifier <- struct{}{} }()
sink := consoleSink
fileSink := getLogFile(opts, version, ext)
if fileSink != nil {
defer finalizeLogFile(fileSink)
if opts.NoOutput {
sink = fileSink
} else {
sink = io.MultiWriter(consoleSink, fileSink)
}
}
if bytes, err := io.Copy(sink, logSource); err != nil {
log.Printf("%s: writting logs failed after %d bytes: %s", fileSink.Name(), bytes, err)
}
}
func persistOutputStreams(opts *Options, version string, cmd *exec.Cmd) (chan struct{}, error) {
var (
done = make(chan struct{})
copyNotifier = make(chan struct{}, 2)
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stderr: %w", err)
}
go copyLogs(opts, os.Stdout, version, ".log", stdout, copyNotifier)
go copyLogs(opts, os.Stderr, version, ".error.log", stderr, copyNotifier)
go func() {
<-copyNotifier
<-copyNotifier
close(copyNotifier)
close(done)
}()
return done, nil
}
func execute(opts *Options, args []string) (cont bool, err error) {
file, err := registry.GetFile(platform(opts.Identifier))
if err != nil {
return true, fmt.Errorf("could not get component: %w", err)
}
// check permission
if err := fixExecPerm(file.Path()); err != nil {
return true, err
}
log.Printf("starting %s %s\n", file.Path(), strings.Join(args, " "))
// create command
exc := exec.Command(file.Path(), args...) //nolint:gosec // everything is okay
if !runningInConsole && opts.AllowHidingWindow {
// Windows only:
// only hide (all) windows of program if we are not running in console and windows may be hidden
hideWindow(exc)
}
outputsWritten, err := persistOutputStreams(opts, file.Version(), exc)
if err != nil {
return true, err
}
interrupt, err := getProcessSignalFunc(exc)
if err != nil {
return true, err
}
err = exc.Start()
if err != nil {
return true, fmt.Errorf("failed to start %s: %w", opts.Identifier, err)
}
childIsRunning.Set()
// wait for completion
finished := make(chan error, 1)
go func() {
defer close(finished)
<-outputsWritten
// wait for process to return
finished <- exc.Wait()
// update status
childIsRunning.UnSet()
}()
// state change listeners
select {
case <-shuttingDown:
if err := interrupt(); err != nil {
log.Printf("failed to signal %s to shutdown: %s\n", opts.Identifier, err)
err = exc.Process.Kill()
if err != nil {
return false, fmt.Errorf("failed to kill %s: %w", opts.Identifier, err)
}
return false, fmt.Errorf("killed %s", opts.Identifier)
}
// wait until shut down
select {
case <-finished:
case <-time.After(11 * time.Second): // portmaster core prints stack if not able to shutdown in 10 seconds
// kill
err = exc.Process.Kill()
if err != nil {
return false, fmt.Errorf("failed to kill %s: %s", opts.Identifier, err)
}
return false, fmt.Errorf("killed %s", opts.Identifier)
}
return false, nil
case err := <-finished:
return parseExitError(err)
}
}
func getProcessSignalFunc(cmd *exec.Cmd) (func() error, error) {
if stdinSignals {
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stdin: %w", err)
}
return func() error {
_, err := fmt.Fprintln(stdin, "SIGINT")
return err
}, nil
}
return func() error {
return cmd.Process.Signal(os.Interrupt)
}, nil
}
func parseExitError(err error) (restart bool, errWithCtx error) {
if err == nil {
// clean and coordinated exit
return false, nil
}
if exErr, ok := err.(*exec.ExitError); ok {
switch exErr.ProcessState.ExitCode() {
case 0:
return false, fmt.Errorf("clean exit with error: %w", err)
case 1:
return true, fmt.Errorf("error during execution: %w", err)
case RestartExitCode:
return true, nil
default:
return true, fmt.Errorf("unknown exit code %w", exErr)
}
}
return true, fmt.Errorf("unexpected error type: %w", err)
}

View File

@@ -0,0 +1,140 @@
package main
// Based on the official Go examples from
// https://github.com/golang/sys/blob/master/windows/svc/example
// by The Go Authors.
// Original LICENSE (sha256sum: 2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067) can be found in vendor/pkg cache directory.
import (
"fmt"
"log"
"sync"
"time"
"github.com/spf13/cobra"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
"golang.org/x/sys/windows/svc/eventlog"
)
var (
runCoreService = &cobra.Command{
Use: "core-service",
Short: "Run the Portmaster Core as a Windows Service",
RunE: runAndLogControlError(func(cmd *cobra.Command, args []string) error {
return runService(cmd, &Options{
Identifier: "core/portmaster-core",
AllowDownload: true,
AllowHidingWindow: false,
NoOutput: true,
}, args)
}),
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
// wait groups
runWg sync.WaitGroup
finishWg sync.WaitGroup
)
func init() {
rootCmd.AddCommand(runCoreService)
}
const serviceName = "PortmasterCore"
type windowsService struct{}
func (ws *windowsService) Execute(args []string, changeRequests <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
service:
for {
select {
case <-startupComplete:
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
case <-shuttingDown:
changes <- svc.Status{State: svc.StopPending}
break service
case c := <-changeRequests:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
initiateShutdown(nil)
default:
log.Printf("unexpected control request: #%d\n", c)
}
}
}
// define return values
if getShutdownError() != nil {
ssec = true // this error is specific to this service (ie. custom)
errno = 1 // generic error, check logs / windows events
}
// wait until everything else is finished
finishWg.Wait()
// send stopped status
changes <- svc.Status{State: svc.Stopped}
// wait a little for the status to reach Windows
time.Sleep(100 * time.Millisecond)
return ssec, errno
}
func runService(cmd *cobra.Command, opts *Options, cmdArgs []string) error {
// check if we are running interactively
isDebug, err := svc.IsAnInteractiveSession()
if err != nil {
return fmt.Errorf("could not determine if running interactively: %s", err)
}
// select service run type
svcRun := svc.Run
if isDebug {
log.Printf("WARNING: running interactively, switching to debug execution (no real service).\n")
svcRun = debug.Run
}
// open eventlog
elog, err := eventlog.Open(serviceName)
if err != nil {
return fmt.Errorf("failed to open eventlog: %s", err)
}
defer elog.Close()
runWg.Add(2)
finishWg.Add(1)
// run service client
go func() {
sErr := svcRun(serviceName, &windowsService{})
initiateShutdown(sErr)
runWg.Done()
}()
// run service
go func() {
// run slightly delayed
time.Sleep(250 * time.Millisecond)
err := run(cmd, opts, getExecArgs(opts, cmdArgs))
initiateShutdown(err)
finishWg.Done()
runWg.Done()
}()
runWg.Wait()
err = getShutdownError()
if err != nil {
log.Printf("%s service experienced an error: %s\n", serviceName, err)
_ = elog.Error(1, fmt.Sprintf("%s experienced an error: %s", serviceName, err))
}
return err
}

View File

@@ -0,0 +1,41 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(showCmd)
// sub-commands of show are registered using registerComponent
}
var showCmd = &cobra.Command{
Use: "show",
PersistentPreRunE: func(*cobra.Command, []string) error {
// all show sub-commands need the data-root but no logging.
return configureDataRoot()
},
Short: "Show the command to run a Portmaster component yourself",
}
func show(cmd *cobra.Command, opts *Options, cmdArgs []string) error {
// get original arguments
args := getExecArgs(opts, cmdArgs)
// adapt identifier
if onWindows {
opts.Identifier += ".exe"
}
file, err := registry.GetFile(platform(opts.Identifier))
if err != nil {
return fmt.Errorf("could not get component: %s", err)
}
fmt.Printf("%s %s\n", file.Path(), strings.Join(args, " "))
return nil
}

View File

@@ -0,0 +1,43 @@
package main
import (
"sync"
)
var (
startupComplete = make(chan struct{}) // signal that the start procedure completed (is never closed, just signaled once)
shuttingDown = make(chan struct{}) // signal that we are shutting down (will be closed, may not be closed directly, use initiateShutdown)
//nolint:deadcode,unused // false positive on linux, currently used by windows only
shutdownError error // protected by shutdownLock
shutdownLock sync.Mutex
)
func initiateShutdown(err error) {
shutdownLock.Lock()
defer shutdownLock.Unlock()
select {
case <-shuttingDown:
return
default:
shutdownError = err
close(shuttingDown)
}
}
func isShutdown() bool {
select {
case <-shuttingDown:
return true
default:
return false
}
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only
func getShutdownError() error {
shutdownLock.Lock()
defer shutdownLock.Unlock()
return shutdownError
}

View File

@@ -0,0 +1,14 @@
package main
func init() {
registerComponent([]Options{
{
Name: "Portmaster SnoreToast Notifier",
ShortIdentifier: "notifier-snoretoast", // would otherwise conflict with notifier.
Identifier: "notifier/portmaster-snoretoast",
AllowDownload: false,
AllowHidingWindow: true,
SuppressArgs: true,
},
})
}

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"fmt"
"runtime"
"github.com/safing/portbase/log"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(updatesCmd)
}
var updatesCmd = &cobra.Command{
Use: "update",
Short: "Run a manual update process",
RunE: func(cmd *cobra.Command, args []string) error {
return downloadUpdates()
},
}
func downloadUpdates() error {
// mark required updates
if onWindows {
registry.MandatoryUpdates = []string{
platform("core/portmaster-core.exe"),
platform("start/portmaster-start.exe"),
platform("app/portmaster-app.exe"),
platform("notifier/portmaster-notifier.exe"),
platform("notifier/portmaster-snoretoast.exe"),
}
} else {
registry.MandatoryUpdates = []string{
platform("core/portmaster-core"),
platform("start/portmaster-start"),
platform("app/portmaster-app"),
platform("notifier/portmaster-notifier"),
}
}
// ok, now we want logging.
err := log.Start()
if err != nil {
fmt.Printf("failed to start logging: %s\n", err)
}
return registry.DownloadUpdates(context.TODO())
}
func platform(identifier string) string {
return fmt.Sprintf("%s_%s/%s", runtime.GOOS, runtime.GOARCH, identifier)
}

View File

@@ -0,0 +1,79 @@
package main
import (
"fmt"
"os"
"runtime"
"sort"
"strings"
"text/tabwriter"
"github.com/safing/portbase/info"
"github.com/spf13/cobra"
)
var showShortVersion bool
var showAllVersions bool
var versionCmd = &cobra.Command{
Use: "version",
Short: "Display various portmaster versions",
Args: cobra.NoArgs,
PersistentPreRunE: func(*cobra.Command, []string) error {
if showAllVersions {
// if we are going to show all component versions
// we need the dataroot to be configured.
if err := configureDataRoot(); err != nil {
return err
}
}
return nil
},
RunE: func(*cobra.Command, []string) error {
if !showAllVersions {
if showShortVersion {
fmt.Println(info.Version())
}
fmt.Println(info.FullVersion())
return nil
}
fmt.Printf("portmaster-start %s\n\n", info.Version())
fmt.Printf("Components:\n")
all := registry.Export()
keys := make([]string, 0, len(all))
for identifier := range all {
keys = append(keys, identifier)
}
sort.Strings(keys)
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
for _, identifier := range keys {
res := all[identifier]
if showShortVersion {
// in "short" mode, skip all resources that are irrelevant on that platform
if !strings.HasPrefix(identifier, "all") && !strings.HasPrefix(identifier, runtime.GOOS) {
continue
}
}
fmt.Fprintf(tw, " %s\t%s\n", identifier, res.SelectedVersion.VersionNumber)
}
tw.Flush()
return nil
},
}
func init() {
flags := versionCmd.Flags()
{
flags.BoolVar(&showShortVersion, "short", false, "Print only the verison number.")
flags.BoolVar(&showAllVersions, "all", false, "Dump versions for all components.")
}
rootCmd.AddCommand(versionCmd)
}