arblant
firefox-vpn-client / core / cmd / firefox-vpn-core / main.go
main.go211 lines · 8.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
//go:generate go run ../../tools/winres

package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"strings"
	"syscall"

	"golang.org/x/term"

	"github.com/armanlife/firefox-vpn-client/core/internal/desktop"
	"github.com/armanlife/firefox-vpn-client/core/internal/logx"
	"github.com/armanlife/firefox-vpn-client/core/internal/service"
	"github.com/armanlife/firefox-vpn-client/core/internal/update"
	"github.com/armanlife/firefox-vpn-client/core/internal/version"
)

func main() {
	flag.Usage = func() {
		out := flag.CommandLine.Output()
		fmt.Fprintln(out, "Usage: firefox-vpn-core [-data-dir DIR] [-plain] [-verbose]")
		fmt.Fprintln(out, "       firefox-vpn-core -tray [-data-dir DIR]")
		fmt.Fprintln(out, "       firefox-vpn-core -app [-data-dir DIR] [-verbose]   (started by the desktop app)")
		fmt.Fprintln(out, "       firefox-vpn-core -update -data-dir DIR   (a copy started by the app's core, to install an update)")
		fmt.Fprintln(out, "       firefox-vpn-core -add-account | -list-accounts | -set-dashboard-login [-data-dir DIR]")
		fmt.Fprintln(out, "       firefox-vpn-core -setup-server USERNAME [-data-dir DIR]   (install.sh, new Linux servers)")
		fmt.Fprintln(out)
		fmt.Fprintln(out, "Without a command, runs the proxy service and its web dashboard (default")
		fmt.Fprintln(out, "http://127.0.0.1:10820) with a status screen in the terminal (-plain prints the")
		fmt.Fprintln(out, "log instead). Settings live in user-data/config.json next to the executable;")
		fmt.Fprintln(out, "routing (routing.enabled, off by default) sends the sites and networks of")
		fmt.Fprintln(out, "routing.region (default ir) directly and everything else through Mozilla.")
		fmt.Fprintln(out)
		fmt.Fprintln(out, "-app runs the service for the desktop app: no tray icon or status screen, the")
		fmt.Fprintln(out, "log in the log file only. It stops on POST /api/app/exit, Ctrl+C, SIGTERM, or")
		fmt.Fprintln(out, "when its standard input (a pipe the app keeps open) ends. Exit status: 0 after")
		fmt.Fprintln(out, "a stop, 1 on an error, 2 on bad arguments, 3 when another copy already serves")
		fmt.Fprintln(out, "the data folder, 4 when another program holds the dashboard address. On a")
		fmt.Fprintln(out, "failure the first line on the standard error is \"app-error: <code> <detail>\".")
		fmt.Fprintln(out)
		flag.PrintDefaults()
	}
	dataDir := flag.String("data-dir", "", "Data folder (default: the desktop app's folder when the core sits beside firefox-vpn(.exe), else the \"user-data\" folder next to the executable)")
	addAccount := flag.Bool("add-account", false, "Sign in on the terminal and add the account, then exit")
	listAccounts := flag.Bool("list-accounts", false, "List the accounts with status and quota, then exit")
	status := flag.Bool("status", false, "Print the running service's proxy state, account, and data left in a few lines, then exit")
	setLogin := flag.Bool("set-dashboard-login", false, "Set or remove the dashboard username and password on the terminal, then exit")
	setupServer := flag.String("setup-server", "", "Open the dashboard and the proxy to the network (the dashboard login for USERNAME, \"-\" for a random one, and a random proxy login, each with a random password), print the logins, then exit (install.sh runs it on a new server)")
	tray := flag.Bool("tray", false, "Show the tray icon; runs the service in-process unless one is already running")
	app := flag.Bool("app", false, "Run the service for the desktop app (no tray icon or status screen); stops when standard input ends")
	applyUpdate := flag.Bool("update", false, "Install the desktop app's downloaded update (the app's core starts a copy of itself with it; the log goes to the log file)")
	showVersion := flag.Bool("version", false, "Print the version and exit")
	plain := flag.Bool("plain", false, "Print the log instead of the status screen")
	verbose := flag.Bool("verbose", false, "Log CONNECT destinations and debug lines regardless of the config")
	checkQUIC := flag.Bool("check-quic", false, "Try QUIC (HTTP/3) handshakes to Mozilla's proxy servers and to well-known sites, print the results, then exit; tells whether this network lets HTTP/3 through")
	flag.Parse()
	if *showVersion {
		fmt.Println(version.Current)
		return
	}
	if *checkQUIC {
		if !runQUICCheck(os.Stdout) {
			os.Exit(1)
		}
		return
	}
	if flag.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "unexpected arguments: %s\n", strings.Join(flag.Args(), " "))
		flag.Usage()
		os.Exit(2)
	}

	if err := runCommand(commandOptions{
		dataDir:      *dataDir,
		addAccount:   *addAccount,
		listAccounts: *listAccounts,
		status:       *status,
		setLogin:     *setLogin,
		setupServer:  *setupServer,
		tray:         *tray,
		app:          *app,
		update:       *applyUpdate,
		plain:        *plain,
		verbose:      *verbose,
	}); err != nil {
		logx.Error("%s", logx.Err(err))
		if *app {
			// The log goes to the file only in this mode; the app reads
			// the reason from the standard error.
			os.Exit(desktop.ReportAppError(os.Stderr, err))
		}
		os.Exit(1)
	}
}

// commandOptions are the parsed command-line options.
type commandOptions struct {
	dataDir      string
	addAccount   bool
	listAccounts bool
	status       bool
	setLogin     bool
	setupServer  string
	tray         bool
	app          bool
	update       bool
	plain        bool
	verbose      bool
}

// runCommand runs the requested one-shot command, or the service when no
// command is given.
func runCommand(opts commandOptions) error {
	commands := 0
	for _, set := range []bool{opts.addAccount, opts.listAccounts, opts.status, opts.setLogin, opts.setupServer != "", opts.tray, opts.app, opts.update} {
		if set {
			commands++
		}
	}
	if commands > 1 {
		return errors.New("use only one of -add-account, -list-accounts, -status, -set-dashboard-login, -setup-server, -tray, -app, -update")
	}
	if opts.app && opts.plain {
		return errors.New("-plain applies to the terminal; -app logs to the log file only")
	}
	paths, err := service.ResolveDataPaths(opts.dataDir)
	if err != nil {
		return err
	}
	switch {
	case opts.addAccount:
		return runAddAccount(paths)
	case opts.status:
		return runStatus(paths, os.Stdout)
	case opts.listAccounts:
		return runListAccounts(paths)
	case opts.setLogin:
		return runSetDashboardLogin(paths)
	case opts.setupServer != "":
		return runSetupServer(paths, opts.setupServer)
	case opts.update:
		// A copy of the core, started apart by the app's core: no console,
		// and nobody reads its output.
		logx.EnableFile(paths.LogDir, false)
		return update.Apply(paths.Dir)
	case opts.app:
		if term.IsTerminal(int(os.Stdin.Fd())) {
			// Typed in a terminal: the app starts it with a pipe.
			fmt.Fprintln(os.Stderr, appInTerminalHint)
		}
		// Standard output and error are pipes the app may never read; a
		// full pipe would block logging, so the log goes to the file only.
		logx.EnableFile(paths.LogDir, false)
		if desktop.StartedWithOwnConsole() {
			// Windows opened a console window for this process (a GUI
			// parent); the app is the window.
			desktop.DetachConsole()
		}
		return desktop.RunApp(paths, opts.verbose)
	case opts.tray:
		if desktop.StartedWithOwnConsole() {
			// Started from Explorer (a shortcut): the tray icon replaces
			// the console window.
			desktop.DetachConsole()
			logx.EnableFile(paths.LogDir, false)
		} else {
			logx.EnableFile(paths.LogDir, true)
		}
		return desktop.RunTray(paths, opts.verbose)
	}
	if !opts.plain {
		// An interactive terminal (including a double-click on Windows)
		// gets the status screen; anything else gets the plain log.
		if shown, err := desktop.RunConsole(paths, opts.verbose); shown {
			return err
		}
	}
	return runConsole(paths, opts.verbose)
}

// appInTerminalHint tells someone who typed -app what that mode is for: it
// waits for the desktop app to turn the proxy on.
const appInTerminalHint = `firefox-vpn-core: -app is the desktop app's mode: the proxy stays off until the
app or the panel's power button turns it on, the log goes to user-data/logs
only, and closing this terminal stops it. In a terminal or as a service, run
firefox-vpn-core without -app: the proxy is on from the start (-plain prints
the log).`

// runConsole runs the service in the terminal until Ctrl+C or SIGTERM.
func runConsole(paths service.DataPaths, verbose bool) error {
	logx.EnableFile(paths.LogDir, true)
	d, err := service.NewDaemon(paths, verbose)
	if err != nil {
		return err
	}
	signals := make(chan os.Signal, 1)
	signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
	defer signal.Stop(signals)
	go func() {
		if _, ok := <-signals; ok {
			logx.Info("stopping")
			d.Shutdown()
		}
	}()
	return d.Run()
}