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
No known key found for this signature in database
GPG key ID: E8CD2DA160925A6D
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

View file

@ -19,9 +19,7 @@ import (
"golang.org/x/sys/windows/svc/mgr"
)
const (
exeSuffix = ".exe"
)
const exeSuffix = ".exe"
func init() {
rootCmd.AddCommand(installCmd)
@ -57,39 +55,18 @@ var uninstallService = &cobra.Command{
RunE: uninstallWindowsService,
}
func getExePath() (string, error) {
// get own filepath
prog := os.Args[0]
p, err := filepath.Abs(prog)
func getAbsBinaryPath() (string, error) {
p, err := filepath.Abs(os.Args[0])
if err != nil {
return "", err
}
// check if the path is valid
fi, err := os.Stat(p)
if err == nil {
if !fi.Mode().IsDir() {
return p, nil
}
err = fmt.Errorf("%s is directory", p)
}
// check if we have a .exe extension, add and check if not
if filepath.Ext(p) == "" {
p += exeSuffix
fi, err = os.Stat(p)
if err == nil {
if !fi.Mode().IsDir() {
return p, nil
}
err = fmt.Errorf("%s is directory", p)
}
}
return "", err
return p, nil
}
func getServiceExecCommand(exePath string, escape bool) []string {
return []string{
maybeEscape(exePath, escape),
"run",
"core-service",
"--data",
maybeEscape(dataRoot.Path, escape),
@ -126,7 +103,7 @@ func getRecoveryActions() (recoveryActions []mgr.RecoveryAction, resetPeriod uin
func installWindowsService(cmd *cobra.Command, args []string) error {
// get exe path
exePath, err := getExePath()
exePath, err := getAbsBinaryPath()
if err != nil {
return fmt.Errorf("failed to get exe path: %s", err)
}
@ -180,7 +157,7 @@ func uninstallWindowsService(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
defer m.Disconnect() //nolint:errcheck // TODO
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)

View file

@ -35,13 +35,7 @@ func checkAndCreateInstanceLock(name string) (pid int32, err error) {
// check if process exists
p, err := processInfo.NewProcess(int32(parsedPid))
if err == nil {
// TODO: remove this workaround as soon as NewProcess really returns an error on windows when the process does not exist
// Issue: https://github.com/shirou/gopsutil/issues/729
_, err = p.Name()
if err == nil {
// process exists
return p.Pid, nil
}
return p.Pid, nil
}
// else create new lock

View file

@ -35,7 +35,7 @@ func initializeLogFile(logFilePath string, identifier string, version string) *o
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, logFilePath)
finalizeLogFile(logFile)
return nil
}
c.AppendAsBlock(metaSection)
@ -46,14 +46,16 @@ func initializeLogFile(logFilePath string, identifier string, version string) *o
_, err = logFile.Write(c.CompileData())
if err != nil {
log.Printf("failed to write header for log file %s: %s\n", logFilePath, err)
finalizeLogFile(logFile, logFilePath)
finalizeLogFile(logFile)
return nil
}
return logFile
}
func finalizeLogFile(logFile *os.File, logFilePath string) {
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)
@ -61,28 +63,38 @@ func finalizeLogFile(logFile *os.File, logFilePath string) {
// check file size
stat, err := os.Stat(logFilePath)
if err == nil {
// delete if file is smaller than
if stat.Size() < 200 { // header + info is about 150 bytes
err := os.Remove(logFilePath)
if err != nil {
log.Printf("failed to delete empty log file %s: %s\n", logFilePath, err)
}
}
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 initControlLogFile() *os.File {
func getLogFile(options *Options, version, ext string) *os.File {
// check logging dir
logFileBasePath := filepath.Join(logsRoot.Path, "control")
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.log", time.Now().UTC().Format("2006-01-02-15-04-05")))
return initializeLogFile(logFilePath, "control/portmaster-control", info.Version())
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
@ -92,44 +104,25 @@ func logControlError(cErr error) {
return
}
// check logging dir
logFileBasePath := filepath.Join(logsRoot.Path, "control")
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.error.log", time.Now().UTC().Format("2006-01-02-15-04-05")))
errorFile := initializeLogFile(logFilePath, "control/portmaster-control", info.Version())
errorFile := getPmStartLogFile(".error.log")
if errorFile == nil {
return
}
defer errorFile.Close()
// write error and close
fmt.Fprintln(errorFile, cErr.Error())
errorFile.Close()
}
//nolint:deadcode,unused // TODO
func logControlStack() {
// check logging dir
logFileBasePath := filepath.Join(logsRoot.Path, "control")
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.stack.log", time.Now().UTC().Format("2006-01-02-15-04-05")))
errorFile := initializeLogFile(logFilePath, "control/portmaster-control", info.Version())
if errorFile == nil {
fp := getPmStartLogFile(".stack.log")
if fp == nil {
return
}
defer fp.Close()
// write error and close
_ = pprof.Lookup("goroutine").WriteTo(errorFile, 2)
errorFile.Close()
_ = pprof.Lookup("goroutine").WriteTo(fp, 2)
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only

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()
}

View file

@ -8,15 +8,15 @@ COL_BOLD="\033[01;01m"
COL_RED="\033[31m"
destDirPart1="../dist"
destDirPart2="control"
destDirPart2="start"
function check {
# output
output="pmctl"
output="portmaster-start"
# get version
version=$(grep "info.Set" main.go | cut -d'"' -f4)
# build versioned file name
filename="portmaster-control_v${version//./-}"
filename="portmaster-start_v${version//./-}"
# platform
platform="${GOOS}_${GOARCH}"
if [[ $GOOS == "windows" ]]; then
@ -28,19 +28,19 @@ function check {
# check if file exists
if [[ -f $destPath ]]; then
echo "[control] $platform $version already built"
echo "[start] $platform $version already built"
else
echo -e "${COL_BOLD}[control] $platform $version${COL_OFF}"
echo -e "${COL_BOLD}[start] $platform $version${COL_OFF}"
fi
}
function build {
# output
output="pmctl"
output="portmaster-start"
# get version
version=$(grep "info.Set" main.go | cut -d'"' -f4)
# build versioned file name
filename="portmaster-control_v${version//./-}"
filename="portmaster-start_v${version//./-}"
# platform
platform="${GOOS}_${GOARCH}"
if [[ $GOOS == "windows" ]]; then
@ -52,19 +52,19 @@ function build {
# check if file exists
if [[ -f $destPath ]]; then
echo "[control] $platform already built in version $version, skipping..."
echo "[start] $platform already built in version $version, skipping..."
return
fi
# build
./build
if [[ $? -ne 0 ]]; then
echo -e "\n${COL_BOLD}[control] $platform: ${COL_RED}BUILD FAILED.${COL_OFF}"
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}[control] $platform: successfully built.${COL_OFF}"
echo -e "\n${COL_BOLD}[start] $platform: successfully built.${COL_OFF}"
}
function check_all {

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

@ -27,7 +27,7 @@ var (
AllowDownload: true,
AllowHidingWindow: false,
NoOutput: true,
})
}, args)
}),
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
@ -41,7 +41,7 @@ var (
)
func init() {
runCmd.AddCommand(runCoreService)
rootCmd.AddCommand(runCoreService)
}
const serviceName = "PortmasterCore"
@ -88,7 +88,7 @@ service:
return ssec, errno
}
func runService(cmd *cobra.Command, opts *Options) error {
func runService(cmd *cobra.Command, opts *Options, cmdArgs []string) error {
// check if we are running interactively
isDebug, err := svc.IsAnInteractiveSession()
if err != nil {
@ -122,7 +122,8 @@ func runService(cmd *cobra.Command, opts *Options) error {
go func() {
// run slightly delayed
time.Sleep(250 * time.Millisecond)
_ = handleRun(cmd, opts) // error handled by shutdown routine
err := run(cmd, opts, getExecArgs(opts, cmdArgs))
initiateShutdown(err)
finishWg.Done()
runWg.Done()
}()

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

@ -5,11 +5,10 @@ import (
)
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)
shutdownInitiated = false // not to be used directly
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 // may not be read or written to directly
shutdownError error // protected by shutdownLock
shutdownLock sync.Mutex
)
@ -17,13 +16,24 @@ func initiateShutdown(err error) {
shutdownLock.Lock()
defer shutdownLock.Unlock()
if !shutdownInitiated {
shutdownInitiated = true
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()

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

@ -26,7 +26,7 @@ func downloadUpdates() error {
if onWindows {
registry.MandatoryUpdates = []string{
platform("core/portmaster-core.exe"),
platform("control/portmaster-control.exe"),
platform("start/portmaster-start.exe"),
platform("app/portmaster-app.exe"),
platform("notifier/portmaster-notifier.exe"),
platform("notifier/portmaster-snoretoast.exe"),
@ -34,7 +34,7 @@ func downloadUpdates() error {
} else {
registry.MandatoryUpdates = []string{
platform("core/portmaster-core"),
platform("control/portmaster-control"),
platform("start/portmaster-start"),
platform("app/portmaster-app"),
platform("notifier/portmaster-notifier"),
}

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)
}

6
pmctl/.gitignore vendored
View file

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

View file

@ -1,227 +0,0 @@
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
databaseDir string
dataRoot *utils.DirStructure
logsRoot *utils.DirStructure
showShortVersion bool
showFullVersion bool
// 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-control",
Short: "Controller for all portmaster components",
PersistentPreRunE: cmdSetup,
RunE: func(cmd *cobra.Command, args []string) error {
if showShortVersion {
fmt.Println(info.Version())
return nil
}
if showFullVersion {
fmt.Println(info.FullVersion())
return nil
}
return cmd.Help()
},
SilenceUsage: true,
}
)
func init() {
// Let cobra ignore if we are running as "GUI" or not
cobra.MousetrapHelpText = ""
rootCmd.PersistentFlags().StringVar(&dataDir, "data", "", "Configures the data directory. Alternatively, this can also be set via the environment variable PORTMASTER_DATA.")
rootCmd.PersistentFlags().StringVar(&databaseDir, "db", "", "Alias to --data (deprecated)")
_ = rootCmd.MarkPersistentFlagDirname("data")
_ = rootCmd.MarkPersistentFlagDirname("db")
rootCmd.Flags().BoolVar(&showFullVersion, "version", false, "Print version of portmaster-control.")
rootCmd.Flags().BoolVar(&showShortVersion, "ver", false, "Print version number only")
}
func main() {
// set meta info
info.Set("Portmaster Control", "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)
} else {
log.Printf("got %s signal, exiting... (not executing anything)\n", sig)
os.Exit(0)
}
}
}
func cmdSetup(cmd *cobra.Command, args []string) (err error) {
// check if we are running in a console (try to attach to parent console if available)
runningInConsole, err = attachToParentConsole()
if err != nil {
log.Printf("failed to attach to parent console: %s\n", err)
os.Exit(1)
}
// check if meta info is ok
err = info.CheckVersion()
if err != nil {
fmt.Println("compile error: please compile using the provided build script")
os.Exit(1)
}
// 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)
// data directory
if !showShortVersion && !showFullVersion {
// set data root
// backwards compatibility
if dataDir == "" {
dataDir = databaseDir
}
// 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()
}
// logs and warning
if !showShortVersion && !showFullVersion && !strings.Contains(cmd.CommandPath(), " show ") {
// 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-control 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.TODO())
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()
}

View file

@ -1,406 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/tevino/abool"
)
const (
restartCode = 23
)
var (
runningInConsole bool
onWindows = runtime.GOOS == "windows"
childIsRunning = abool.NewBool(false)
)
// Options for starting component
type Options struct {
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() {
rootCmd.AddCommand(runCmd)
runCmd.AddCommand(runCore)
runCmd.AddCommand(runApp)
runCmd.AddCommand(runNotifier)
}
var runCmd = &cobra.Command{
Use: "run",
Short: "Run a Portmaster component in the foreground",
}
var runCore = &cobra.Command{
Use: "core",
Short: "Run the Portmaster Core",
RunE: func(cmd *cobra.Command, args []string) error {
return handleRun(cmd, &Options{
Identifier: "core/portmaster-core",
AllowDownload: true,
AllowHidingWindow: true,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
var runApp = &cobra.Command{
Use: "app",
Short: "Run the Portmaster App",
RunE: func(cmd *cobra.Command, args []string) error {
return handleRun(cmd, &Options{
Identifier: "app/portmaster-app",
AllowDownload: false,
AllowHidingWindow: false,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
var runNotifier = &cobra.Command{
Use: "notifier",
Short: "Run the Portmaster Notifier",
RunE: func(cmd *cobra.Command, args []string) error {
return handleRun(cmd, &Options{
Identifier: "notifier/portmaster-notifier",
AllowDownload: false,
AllowHidingWindow: true,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
func handleRun(cmd *cobra.Command, opts *Options) (err error) {
err = run(cmd, opts)
initiateShutdown(err)
return
}
func run(cmd *cobra.Command, opts *Options) (err error) { //nolint:gocognit
// set download option
registry.Online = opts.AllowDownload
// parse identifier
opts.ShortIdentifier = path.Dir(opts.Identifier)
// check for concurrent error (eg. service)
shutdownLock.Lock()
alreadyDead := shutdownInitiated
shutdownLock.Unlock()
if alreadyDead {
return
}
// 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{}{}
}()
// get original arguments
var args []string
if len(os.Args) < 4 {
return cmd.Help()
}
args = os.Args[3:]
if opts.SuppressArgs {
args = nil
}
// adapt identifier
if onWindows {
opts.Identifier += ".exe"
}
// setup logging
// init log file
logFile := initControlLogFile()
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))
}
}
// run
tries := 0
for {
// normal execution
tryAgain := false
tryAgain, err = execute(opts, args)
switch {
case tryAgain && err != nil:
// temporary? execution error
log.Printf("execution of %s failed: %s\n", opts.Identifier, err)
tries++
if tries >= 5 {
log.Println("error seems to be permanent, giving up...")
return err
}
// resilience
time.Sleep(time.Duration(tries) * 2 * time.Second)
if tries >= 2 {
// try updating
updateRegistryIndex()
}
log.Println("trying again...")
case tryAgain && err == nil:
// reset error count
tries = 0
// upgrade
log.Println("restarting by request...")
// update index
log.Println("checking versions...")
updateRegistryIndex()
case !tryAgain && err != nil:
// fatal error
return err
case !tryAgain && err == nil:
// clean exit
log.Printf("%s completed successfully\n", opts.Identifier)
return nil
}
}
}
// nolint:gocyclo,gocognit // TODO: simplify
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: %s", err)
}
// check permission
if !onWindows {
info, err := os.Stat(file.Path())
if err != nil {
return true, fmt.Errorf("failed to get file info on %s: %s", file.Path(), err)
}
if info.Mode() != 0755 {
err := os.Chmod(file.Path(), 0755)
if err != nil {
return true, fmt.Errorf("failed to set exec permissions on %s: %s", file.Path(), err)
}
}
}
log.Printf("starting %s %s\n", file.Path(), strings.Join(args, " "))
// log files
var logFile, errorFile *os.File
logFileBasePath := filepath.Join(logsRoot.Path, opts.ShortIdentifier)
err = logsRoot.EnsureAbsPath(logFileBasePath)
if err != nil {
log.Printf("failed to check/create log file dir %s: %s\n", logFileBasePath, err)
} else {
// open log file
logFilePath := filepath.Join(logFileBasePath, fmt.Sprintf("%s.log", time.Now().UTC().Format("2006-01-02-15-04-05")))
logFile = initializeLogFile(logFilePath, opts.Identifier, file.Version())
if logFile != nil {
defer finalizeLogFile(logFile, logFilePath)
}
// open error log file
errorFilePath := filepath.Join(logFileBasePath, fmt.Sprintf("%s.error.log", time.Now().UTC().Format("2006-01-02-15-04-05")))
errorFile = initializeLogFile(errorFilePath, opts.Identifier, file.Version())
if errorFile != nil {
defer finalizeLogFile(errorFile, errorFilePath)
}
}
// 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)
}
// check if input signals are enabled
inputSignalsEnabled := false
for _, arg := range args {
if strings.HasSuffix(arg, "-input-signals") {
inputSignalsEnabled = true
break
}
}
// consume stdout/stderr
stdout, err := exc.StdoutPipe()
if err != nil {
return true, fmt.Errorf("failed to connect stdout: %s", err)
}
stderr, err := exc.StderrPipe()
if err != nil {
return true, fmt.Errorf("failed to connect stderr: %s", err)
}
var stdin io.WriteCloser
if inputSignalsEnabled {
stdin, err = exc.StdinPipe()
if err != nil {
return true, fmt.Errorf("failed to connect stdin: %s", err)
}
}
// start
err = exc.Start()
if err != nil {
return true, fmt.Errorf("failed to start %s: %s", opts.Identifier, err)
}
childIsRunning.Set()
// start output writers
var wg sync.WaitGroup
wg.Add(2)
go func() {
var logFileError error
if logFile == nil {
_, logFileError = io.Copy(os.Stdout, stdout)
} else {
if opts.NoOutput {
_, logFileError = io.Copy(logFile, stdout)
} else {
_, logFileError = io.Copy(io.MultiWriter(os.Stdout, logFile), stdout)
}
}
if logFileError != nil {
log.Printf("failed write logs: %s\n", logFileError)
}
wg.Done()
}()
go func() {
var errorFileError error
if logFile == nil {
_, errorFileError = io.Copy(os.Stderr, stderr)
} else {
if opts.NoOutput {
_, errorFileError = io.Copy(errorFile, stderr)
} else {
_, errorFileError = io.Copy(io.MultiWriter(os.Stderr, errorFile), stderr)
}
}
if errorFileError != nil {
log.Printf("failed write error logs: %s\n", errorFileError)
}
wg.Done()
}()
// wait for completion
finished := make(chan error)
go func() {
// wait for output writers to complete
wg.Wait()
// wait for process to return
finished <- exc.Wait()
// update status
childIsRunning.UnSet()
// notify manager
close(finished)
}()
// state change listeners
for {
select {
case <-shuttingDown:
// signal process shutdown
if inputSignalsEnabled {
// for windows
_, err = stdin.Write([]byte("SIGINT\n"))
} else {
err = exc.Process.Signal(os.Interrupt)
}
if 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: %s", 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:
if err != nil {
exErr, ok := err.(*exec.ExitError)
if ok {
switch exErr.ProcessState.ExitCode() {
case 0:
// clean exit
return false, fmt.Errorf("clean exit, but with error: %s", err)
case 1:
// error exit
return true, fmt.Errorf("error during execution: %s", err)
case restartCode:
// restart request
log.Printf("restarting %s\n", opts.Identifier)
return true, nil
default:
return true, fmt.Errorf("unexpected error during execution: %s", err)
}
} else {
return true, fmt.Errorf("unexpected error type during execution: %s", err)
}
}
// clean exit
return false, nil
}
}
}

View file

@ -1,90 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(showCmd)
showCmd.AddCommand(showCore)
showCmd.AddCommand(showApp)
showCmd.AddCommand(showNotifier)
}
var showCmd = &cobra.Command{
Use: "show",
Short: "Show the command to run a Portmaster component yourself",
}
var showCore = &cobra.Command{
Use: "core",
Short: "Show command to run the Portmaster Core",
RunE: func(cmd *cobra.Command, args []string) error {
return show(cmd, &Options{
Identifier: "core/portmaster-core",
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
var showApp = &cobra.Command{
Use: "app",
Short: "Show command to run the Portmaster App",
RunE: func(cmd *cobra.Command, args []string) error {
return show(cmd, &Options{
Identifier: "app/portmaster-app",
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
var showNotifier = &cobra.Command{
Use: "notifier",
Short: "Show command to run the Portmaster Notifier",
RunE: func(cmd *cobra.Command, args []string) error {
return show(cmd, &Options{
Identifier: "notifier/portmaster-notifier",
SuppressArgs: true,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
func show(cmd *cobra.Command, opts *Options) error {
// get original arguments
var args []string
if len(os.Args) < 4 {
return cmd.Help()
}
args = os.Args[3:]
if opts.SuppressArgs {
args = nil
}
// 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

@ -1,40 +0,0 @@
package main
import "github.com/spf13/cobra"
func init() {
showCmd.AddCommand(showSnoreToast)
runCmd.AddCommand(runSnoreToast)
}
var showSnoreToast = &cobra.Command{
Use: "notifier-snoretoast",
Short: "Show command to run the Notifier component SnoreToast",
RunE: func(cmd *cobra.Command, args []string) error {
return show(cmd, &Options{
Identifier: "notifier/portmaster-snoretoast",
SuppressArgs: true,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
var runSnoreToast = &cobra.Command{
Use: "notifier-snoretoast",
Short: "Run the Notifier component SnoreToast",
RunE: func(cmd *cobra.Command, args []string) error {
return handleRun(cmd, &Options{
Identifier: "notifier/portmaster-snoretoast",
AllowDownload: false,
AllowHidingWindow: true,
SuppressArgs: true,
})
},
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}