WaitGroup.Go and built-in gomaxprocs handling. Signed-off-by: Jakob Borg <jakob@kastelo.net>
1076 lines
35 KiB
Go
1076 lines
35 KiB
Go
// Copyright (C) 2014 The Syncthing Authors.
|
|
//
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
_ "net/http/pprof" // Need to import this to support STPROFILER.
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime/pprof"
|
|
"slices"
|
|
"strconv"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/alecthomas/kong"
|
|
"github.com/gofrs/flock"
|
|
"github.com/thejerf/suture/v4"
|
|
"github.com/willabides/kongplete"
|
|
|
|
"github.com/syncthing/syncthing/cmd/syncthing/cli"
|
|
"github.com/syncthing/syncthing/cmd/syncthing/decrypt"
|
|
"github.com/syncthing/syncthing/cmd/syncthing/generate"
|
|
"github.com/syncthing/syncthing/internal/db"
|
|
"github.com/syncthing/syncthing/internal/db/sqlite"
|
|
"github.com/syncthing/syncthing/internal/slogutil"
|
|
"github.com/syncthing/syncthing/lib/build"
|
|
"github.com/syncthing/syncthing/lib/config"
|
|
"github.com/syncthing/syncthing/lib/dialer"
|
|
"github.com/syncthing/syncthing/lib/events"
|
|
"github.com/syncthing/syncthing/lib/fs"
|
|
"github.com/syncthing/syncthing/lib/locations"
|
|
"github.com/syncthing/syncthing/lib/osutil"
|
|
"github.com/syncthing/syncthing/lib/protocol"
|
|
"github.com/syncthing/syncthing/lib/svcutil"
|
|
"github.com/syncthing/syncthing/lib/syncthing"
|
|
"github.com/syncthing/syncthing/lib/upgrade"
|
|
)
|
|
|
|
const (
|
|
sigTerm = syscall.Signal(15)
|
|
)
|
|
|
|
const (
|
|
extraUsage = `
|
|
Logging always happens to the command line (stdout) and optionally to the
|
|
file at the path specified by --log-file=path. In addition to an path, the special
|
|
values "default" and "-" may be used. The former logs to DATADIR/syncthing.log
|
|
(see --data), which is the default on Windows, and the latter only to stdout,
|
|
no file, which is the default anywhere else.
|
|
|
|
|
|
Development Settings
|
|
--------------------
|
|
|
|
The following environment variables modify Syncthing's behavior in ways that
|
|
are mostly useful for developers. Use with care. See also the --debug-* options
|
|
above.
|
|
|
|
STTRACE A comma separated string of packages to trace or change log
|
|
level for. The valid package strings are listed below. A log
|
|
level (DEBUG, INFO, WARN or ERROR) can be added after each
|
|
package, separated by a colon. Ex: "model:WARN,nat:DEBUG".
|
|
|
|
STVERSIONEXTRA Add extra information to the version string in logs and the
|
|
version line in the GUI. Can be set to the name of a wrapper
|
|
or tool controlling syncthing to communicate this to the end
|
|
user.
|
|
|
|
GOMAXPROCS Set the maximum number of CPU cores to use. Defaults to all
|
|
available CPU cores.
|
|
|
|
GOGC Percentage of heap growth at which to trigger GC. Default is
|
|
100. Lower numbers keep peak memory usage down, at the price
|
|
of CPU usage (i.e. performance).
|
|
|
|
|
|
Logging Facilities
|
|
------------------
|
|
|
|
The following are valid values for the STTRACE variable:
|
|
|
|
%s
|
|
`
|
|
)
|
|
|
|
var (
|
|
upgradeCheckInterval = 5 * time.Minute
|
|
upgradeRetryInterval = time.Hour
|
|
upgradeCheckKey = "lastUpgradeCheck"
|
|
upgradeTimeKey = "lastUpgradeTime"
|
|
upgradeVersionKey = "lastUpgradeVersion"
|
|
|
|
errTooEarlyUpgradeCheck = fmt.Errorf("last upgrade check happened less than %v ago, skipping", upgradeCheckInterval)
|
|
errTooEarlyUpgrade = fmt.Errorf("last upgrade happened less than %v ago, skipping", upgradeRetryInterval)
|
|
)
|
|
|
|
// The entrypoint struct is the main entry point for the command line parser. The
|
|
// commands and options here are top level commands to syncthing.
|
|
// Cli is just a placeholder for the help text (see main).
|
|
type CLI struct {
|
|
// The directory options are defined at top level and available for all
|
|
// subcommands. Their settings take effect on the `locations` package by
|
|
// way of the command line parser, so anything using `locations.Get` etc
|
|
// will be doing the right thing.
|
|
ConfDir string `name:"config" short:"C" placeholder:"PATH" env:"STCONFDIR" help:"Set configuration directory (config and keys)"`
|
|
DataDir string `name:"data" short:"D" placeholder:"PATH" env:"STDATADIR" help:"Set data directory (database and logs)"`
|
|
HomeDir string `name:"home" short:"H" placeholder:"PATH" env:"STHOMEDIR" help:"Set configuration and data directory"`
|
|
VersionFlag bool `name:"version" help:"Show current version, then exit"`
|
|
|
|
Serve serveCmd `cmd:"" help:"Run Syncthing (default)" default:"withargs"`
|
|
CLI cli.CLI `cmd:"" help:"Command line interface for Syncthing"`
|
|
|
|
Browser browserCmd `cmd:"" help:"Open GUI in browser, then exit"`
|
|
Decrypt decrypt.CLI `cmd:"" help:"Decrypt or verify an encrypted folder"`
|
|
DeviceID deviceIDCmd `cmd:"" help:"Show device ID, then exit"`
|
|
Generate generate.CLI `cmd:"" help:"Generate key and config, then exit"`
|
|
Paths pathsCmd `cmd:"" help:"Show configuration paths, then exit"`
|
|
Upgrade upgradeCmd `cmd:"" help:"Perform or check for upgrade, then exit"`
|
|
Version versionCmd `cmd:"" help:"Show current version, then exit"`
|
|
Debug debugCmd `cmd:"" help:"Various debugging commands"`
|
|
|
|
InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Print commands to install shell completions"`
|
|
}
|
|
|
|
func (c *CLI) AfterApply() error {
|
|
// Executed after parsing command line options but before running actual
|
|
// subcommands
|
|
return setConfigDataLocationsFromFlags(c.HomeDir, c.ConfDir, c.DataDir)
|
|
}
|
|
|
|
// serveCmd are the options for the `syncthing serve` command.
|
|
type serveCmd struct {
|
|
buildSpecificOptions
|
|
|
|
AllowNewerConfig bool `help:"Allow loading newer than current config version" env:"STALLOWNEWERCONFIG"`
|
|
Audit bool `help:"Write events to audit file" env:"STAUDIT"`
|
|
AuditFile string `name:"auditfile" help:"Specify audit file (use \"-\" for stdout, \"--\" for stderr)" placeholder:"PATH" env:"STAUDITFILE"`
|
|
DBMaintenanceInterval time.Duration `help:"Database maintenance interval; set to zero to disable periodic maintenance" default:"8h" env:"STDBMAINTENANCEINTERVAL"`
|
|
DBDeleteRetentionInterval time.Duration `help:"Database deleted item retention interval" default:"10920h" env:"STDBDELETERETENTIONINTERVAL"`
|
|
GUIAddress string `name:"gui-address" help:"Override GUI address (e.g. \"http://192.0.2.42:8443\")" placeholder:"URL" env:"STGUIADDRESS"`
|
|
GUIAPIKey string `name:"gui-apikey" help:"Override GUI API key" placeholder:"API-KEY" env:"STGUIAPIKEY"`
|
|
LogFile string `name:"log-file" aliases:"logfile" help:"Log file name (see below)" default:"${logFile}" placeholder:"PATH" env:"STLOGFILE"`
|
|
LogFlags int `name:"logflags" help:"Deprecated option that does nothing, kept for compatibility" hidden:""`
|
|
LogLevel slog.Level `help:"Log level for all packages (DEBUG,INFO,WARN,ERROR)" env:"STLOGLEVEL" default:"INFO"`
|
|
LogMaxFiles int `name:"log-max-old-files" help:"Number of old files to keep (zero to keep only current)" default:"${logMaxFiles}" placeholder:"N" env:"STLOGMAXOLDFILES"`
|
|
LogMaxSize int `help:"Maximum size of any file (zero to disable log rotation)" default:"${logMaxSize}" placeholder:"BYTES" env:"STLOGMAXSIZE"`
|
|
LogFormatTimestamp string `name:"log-format-timestamp" help:"Format for timestamp, set to empty to disable timestamps" env:"STLOGFORMATTIMESTAMP" default:"${timestampFormat}"`
|
|
LogFormatLevelString bool `name:"log-format-level-string" help:"Whether to include level string in log line" env:"STLOGFORMATLEVELSTRING" default:"${levelString}" negatable:""`
|
|
LogFormatLevelSyslog bool `name:"log-format-level-syslog" help:"Whether to include level as syslog prefix in log line" env:"STLOGFORMATLEVELSYSLOG" default:"${levelSyslog}" negatable:""`
|
|
NoBrowser bool `help:"Do not start browser" env:"STNOBROWSER"`
|
|
NoPortProbing bool `help:"Don't try to find free ports for GUI and listen addresses on first startup" env:"STNOPORTPROBING"`
|
|
NoRestart bool `help:"Do not restart Syncthing when exiting due to API/GUI command, upgrade, or crash" env:"STNORESTART"`
|
|
NoUpgrade bool `help:"Disable automatic upgrades" env:"STNOUPGRADE"`
|
|
Paused bool `help:"Start with all devices and folders paused" env:"STPAUSED"`
|
|
Unpaused bool `help:"Start with all devices and folders unpaused" env:"STUNPAUSED"`
|
|
|
|
// Debug options below
|
|
DebugGUIAssetsDir string `help:"Directory to load GUI assets from" placeholder:"PATH" env:"STGUIASSETS"`
|
|
DebugPerfStats bool `help:"Write running performance statistics to perf-$pid.csv (Unix only)" env:"STPERFSTATS"`
|
|
DebugProfileBlock bool `help:"Write block profiles to block-$pid-$timestamp.pprof every 20 seconds" env:"STBLOCKPROFILE"`
|
|
DebugProfileCPU bool `help:"Write a CPU profile to cpu-$pid.pprof on exit" env:"STCPUPROFILE"`
|
|
DebugProfileHeap bool `help:"Write heap profiles to heap-$pid-$timestamp.pprof each time heap usage increases" env:"STHEAPPROFILE"`
|
|
DebugProfilerListen string `help:"Network profiler listen address" placeholder:"ADDR" env:"STPROFILER" `
|
|
DebugResetDeltaIdxs bool `help:"Reset delta index IDs, forcing a full index exchange"`
|
|
|
|
// Internal options, not shown to users
|
|
InternalRestarting bool `env:"STRESTART" hidden:"1"`
|
|
InternalInnerProcess bool `env:"STMONITORED" hidden:"1"`
|
|
}
|
|
|
|
func defaultVars() kong.Vars {
|
|
vars := kong.Vars{
|
|
"logMaxSize": strconv.Itoa(10 << 20), // 10 MiB
|
|
"logMaxFiles": "3", // plus the current one
|
|
"levelString": strconv.FormatBool(slogutil.DefaultLineFormat.LevelString),
|
|
"levelSyslog": strconv.FormatBool(slogutil.DefaultLineFormat.LevelSyslog),
|
|
"timestampFormat": slogutil.DefaultLineFormat.TimestampFormat,
|
|
}
|
|
|
|
// On non-Windows, we explicitly default to "-" which means stdout. On
|
|
// Windows, the "default" options.logFile will later be replaced with the
|
|
// default path, unless the user has manually specified "-" or
|
|
// something else.
|
|
if build.IsWindows {
|
|
vars["logFile"] = "default"
|
|
} else {
|
|
vars["logFile"] = "-"
|
|
}
|
|
|
|
return vars
|
|
}
|
|
|
|
func main() {
|
|
// Create a parser with an overridden help function to print our extra
|
|
// help info.
|
|
var entrypoint CLI
|
|
parser, err := kong.New(
|
|
&entrypoint,
|
|
kong.ConfigureHelp(kong.HelpOptions{
|
|
NoExpandSubcommands: true,
|
|
Compact: true,
|
|
}),
|
|
kong.Help(helpHandler),
|
|
defaultVars(),
|
|
)
|
|
if err != nil {
|
|
slog.Error("Parsing startup", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
|
|
kongplete.Complete(parser)
|
|
ctx, err := parser.Parse(os.Args[1:])
|
|
parser.FatalIfErrorf(err)
|
|
|
|
if entrypoint.VersionFlag {
|
|
_ = versionCmd{}.Run()
|
|
return
|
|
}
|
|
|
|
err = ctx.Run()
|
|
parser.FatalIfErrorf(err)
|
|
}
|
|
|
|
func helpHandler(options kong.HelpOptions, ctx *kong.Context) error {
|
|
if err := kong.DefaultHelpPrinter(options, ctx); err != nil {
|
|
return err
|
|
}
|
|
if ctx.Command() == "serve" {
|
|
// Help was requested for `syncthing serve`, so we add our extra
|
|
// usage info afte the normal options output.
|
|
fmt.Printf(extraUsage, logPackages())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// serveCmd.Run() is the entrypoint for `syncthing serve`
|
|
func (c *serveCmd) Run() error {
|
|
if c.GUIAddress != "" {
|
|
// The config picks this up from the environment.
|
|
os.Setenv("STGUIADDRESS", c.GUIAddress)
|
|
}
|
|
if c.GUIAPIKey != "" {
|
|
// The config picks this up from the environment.
|
|
os.Setenv("STGUIAPIKEY", c.GUIAPIKey)
|
|
}
|
|
|
|
if c.HideConsole {
|
|
osutil.HideConsole()
|
|
}
|
|
|
|
// Customize the logging early
|
|
slogutil.SetLineFormat(slogutil.LineFormat{
|
|
TimestampFormat: c.LogFormatTimestamp,
|
|
LevelString: c.LogFormatLevelString,
|
|
LevelSyslog: c.LogFormatLevelSyslog,
|
|
})
|
|
slogutil.SetDefaultLevel(c.LogLevel)
|
|
slogutil.SetLevelOverrides(os.Getenv("STTRACE"))
|
|
|
|
// Treat an explicitly empty log file name as no log file
|
|
if c.LogFile == "" {
|
|
c.LogFile = "-"
|
|
}
|
|
if c.LogFile != "default" {
|
|
// We must set this *after* expandLocations above.
|
|
if err := locations.Set(locations.LogFile, c.LogFile); err != nil {
|
|
slog.Error("Failed to set log file path", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
}
|
|
|
|
if c.DebugGUIAssetsDir != "" {
|
|
// The asset dir is blank if STGUIASSETS wasn't set, in which case we
|
|
// should look for extra assets in the default place.
|
|
if err := locations.Set(locations.GUIAssets, c.DebugGUIAssetsDir); err != nil {
|
|
slog.Error("Failed to set GUI assets path", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
}
|
|
|
|
// Ensure that our config and data directories exist.
|
|
for _, loc := range []locations.BaseDirEnum{locations.ConfigBaseDir, locations.DataBaseDir} {
|
|
if err := syncthing.EnsureDir(locations.GetBaseDir(loc), 0o700); err != nil {
|
|
slog.Error("Failed to ensure directory exists", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
}
|
|
|
|
if c.InternalInnerProcess {
|
|
c.syncthingMain()
|
|
} else {
|
|
c.monitorMain()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openGUI() error {
|
|
cfg, err := loadOrDefaultConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if guiCfg := cfg.GUI(); guiCfg.Enabled {
|
|
if err := openURL(guiCfg.URL()); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
slog.Error("Browser: GUI is currently disabled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func logPackages() string {
|
|
packages := slogutil.PackageDescrs()
|
|
|
|
// Get a sorted list of names
|
|
names := slices.Sorted(maps.Keys(packages))
|
|
maxLen := 0
|
|
for _, name := range names {
|
|
if len(name) > maxLen {
|
|
maxLen = len(name)
|
|
}
|
|
}
|
|
|
|
// Format the choices
|
|
b := new(bytes.Buffer)
|
|
for _, name := range names {
|
|
fmt.Fprintf(b, " %-*s - %s\n", maxLen, name, packages[name])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
type errNoUpgrade struct {
|
|
current, latest string
|
|
}
|
|
|
|
func (e *errNoUpgrade) Error() string {
|
|
return fmt.Sprintf("no upgrade available (current %q >= latest %q).", e.current, e.latest)
|
|
}
|
|
|
|
func checkUpgrade() (upgrade.Release, error) {
|
|
cfg, err := loadOrDefaultConfig()
|
|
if err != nil {
|
|
return upgrade.Release{}, err
|
|
}
|
|
opts := cfg.Options()
|
|
release, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
|
|
if err != nil {
|
|
return upgrade.Release{}, err
|
|
}
|
|
|
|
if upgrade.CompareVersions(release.Tag, build.Version) <= 0 {
|
|
return upgrade.Release{}, &errNoUpgrade{build.Version, release.Tag}
|
|
}
|
|
|
|
slog.Info("Upgrade available", "current", build.Version, "latest", release.Tag)
|
|
return release, nil
|
|
}
|
|
|
|
func upgradeViaRest() error {
|
|
cfg, err := loadOrDefaultConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
u, err := url.Parse(cfg.GUI().URL())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.Path = path.Join(u.Path, "rest/system/upgrade")
|
|
target := u.String()
|
|
r, _ := http.NewRequest(http.MethodPost, target, nil)
|
|
r.Header.Set("X-Api-Key", cfg.GUI().APIKey)
|
|
|
|
tr := &http.Transport{
|
|
DialContext: dialer.DialContext,
|
|
Proxy: http.ProxyFromEnvironment,
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
client := &http.Client{
|
|
Transport: tr,
|
|
Timeout: 60 * time.Second,
|
|
}
|
|
resp, err := client.Do(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bs, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errors.New(string(bs))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *serveCmd) syncthingMain() {
|
|
if c.DebugProfileBlock {
|
|
startBlockProfiler()
|
|
}
|
|
if c.DebugProfileHeap {
|
|
startHeapProfiler()
|
|
}
|
|
if c.DebugPerfStats {
|
|
startPerfStats()
|
|
}
|
|
|
|
// Print our version information up front, so any crash that happens
|
|
// early etc. will have it available.
|
|
slog.Info(build.LongVersion) //nolint:sloglint
|
|
|
|
// Ensure that we have a certificate and key.
|
|
cert, err := syncthing.LoadOrGenerateCertificate(
|
|
locations.Get(locations.CertFile),
|
|
locations.Get(locations.KeyFile),
|
|
)
|
|
if err != nil {
|
|
slog.Error("Failed to load/generate certificate", slogutil.Error(err))
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Ensure we are the only running instance
|
|
lf := flock.New(locations.Get(locations.LockFile))
|
|
locked, err := lf.TryLock()
|
|
if err != nil {
|
|
slog.Error("Failed to acquire lock", slogutil.Error(err))
|
|
os.Exit(1)
|
|
} else if !locked {
|
|
slog.Error("Failed to acquire lock: is another Syncthing instance already running?")
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// earlyService is a supervisor that runs the services needed for or
|
|
// before app startup; the event logger, and the config service.
|
|
spec := svcutil.SpecWithDebugLogger()
|
|
earlyService := suture.New("early", spec)
|
|
earlyService.ServeBackground(ctx)
|
|
|
|
evLogger := events.NewLogger()
|
|
earlyService.Add(evLogger)
|
|
|
|
cfgWrapper, err := syncthing.LoadConfigAtStartup(locations.Get(locations.ConfigFile), cert, evLogger, c.AllowNewerConfig, c.NoPortProbing)
|
|
if err != nil {
|
|
slog.Error("Failed to initialize config", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
earlyService.Add(cfgWrapper)
|
|
config.RegisterInfoMetrics(cfgWrapper)
|
|
|
|
// Candidate builds should auto upgrade. Make sure the option is set,
|
|
// unless we are in a build where it's disabled or the STNOUPGRADE
|
|
// environment variable is set.
|
|
|
|
if build.IsCandidate && !upgrade.DisabledByCompilation && !c.NoUpgrade {
|
|
cfgWrapper.Modify(func(cfg *config.Configuration) {
|
|
slog.Info("Automatic upgrade is always enabled for candidate releases")
|
|
if cfg.Options.AutoUpgradeIntervalH == 0 || cfg.Options.AutoUpgradeIntervalH > 24 {
|
|
cfg.Options.AutoUpgradeIntervalH = 12
|
|
// Set the option into the config as well, as the auto upgrade
|
|
// loop expects to read a valid interval from there.
|
|
}
|
|
// We don't tweak the user's choice of upgrading to pre-releases or
|
|
// not, as otherwise they cannot step off the candidate channel.
|
|
})
|
|
}
|
|
|
|
migratingAPICtx, migratingAPICancel := context.WithCancel(ctx)
|
|
if cfgWrapper.GUI().Enabled {
|
|
// Start a temporary API server during the migration. It will wait
|
|
// startDelay until actually starting, so that if we quickly pass
|
|
// through the migration steps (e.g., there was nothing to migrate)
|
|
// and cancel the context, it will never even start.
|
|
api := migratingAPI{
|
|
addr: cfgWrapper.GUI().Address(),
|
|
startDelay: 5 * time.Second,
|
|
}
|
|
go api.Serve(migratingAPICtx)
|
|
}
|
|
|
|
if err := syncthing.TryMigrateDatabase(ctx, c.DBDeleteRetentionInterval); err != nil {
|
|
slog.Error("Failed to migrate old-style database", slogutil.Error(err))
|
|
os.Exit(1)
|
|
}
|
|
|
|
sdb, err := syncthing.OpenDatabase(locations.Get(locations.Database), c.DBDeleteRetentionInterval)
|
|
if err != nil {
|
|
slog.Error("Error opening database", slogutil.Error(err))
|
|
os.Exit(1)
|
|
}
|
|
|
|
migratingAPICancel() // we're done with the temporary API server
|
|
|
|
// Check if auto-upgrades is possible, and if yes, and it's enabled do an initial
|
|
// upgrade immediately. The auto-upgrade routine can only be started
|
|
// later after App is initialised.
|
|
autoUpgradePossible := c.autoUpgradePossible()
|
|
if autoUpgradePossible && cfgWrapper.Options().AutoUpgradeEnabled() {
|
|
// try to do upgrade directly and log the error if relevant.
|
|
miscDB := db.NewMiscDB(sdb)
|
|
release, err := initialAutoUpgradeCheck(miscDB)
|
|
if err == nil {
|
|
err = upgrade.To(release)
|
|
}
|
|
if err != nil {
|
|
var noUpgradeErr *errNoUpgrade
|
|
if errors.As(err, &noUpgradeErr) || errors.Is(err, errTooEarlyUpgradeCheck) || errors.Is(err, errTooEarlyUpgrade) {
|
|
slog.Debug("Initial automatic upgrade", slogutil.Error(err))
|
|
} else {
|
|
slog.Info("Initial automatic upgrade", slogutil.Error(err))
|
|
}
|
|
} else {
|
|
slog.Info("Upgraded, should exit now", "newVersion", release.Tag)
|
|
os.Exit(svcutil.ExitUpgrade.AsInt())
|
|
}
|
|
}
|
|
|
|
if c.Unpaused {
|
|
setPauseState(cfgWrapper, false)
|
|
} else if c.Paused {
|
|
setPauseState(cfgWrapper, true)
|
|
}
|
|
|
|
appOpts := syncthing.Options{
|
|
NoUpgrade: c.NoUpgrade,
|
|
ProfilerAddr: c.DebugProfilerListen,
|
|
ResetDeltaIdxs: c.DebugResetDeltaIdxs,
|
|
DBMaintenanceInterval: c.DBMaintenanceInterval,
|
|
}
|
|
|
|
if c.Audit || cfgWrapper.Options().AuditEnabled {
|
|
slog.Info("Auditing is enabled")
|
|
|
|
auditFile := cfgWrapper.Options().AuditFile
|
|
|
|
// Ignore config option if command-line option is set
|
|
if c.AuditFile != "" {
|
|
slog.Debug("Using the audit file from the command-line parameter", slogutil.FilePath(c.AuditFile))
|
|
auditFile = c.AuditFile
|
|
}
|
|
|
|
appOpts.AuditWriter = auditWriter(auditFile)
|
|
}
|
|
|
|
app, err := syncthing.New(cfgWrapper, sdb, evLogger, cert, appOpts)
|
|
if err != nil {
|
|
slog.Error("Failed to start Syncthing", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
|
|
if autoUpgradePossible {
|
|
go autoUpgrade(cfgWrapper, app, evLogger)
|
|
}
|
|
|
|
setupSignalHandling(app)
|
|
|
|
if c.DebugProfileCPU {
|
|
f, err := os.Create(fmt.Sprintf("cpu-%d.pprof", os.Getpid()))
|
|
if err != nil {
|
|
slog.Error("Failed to create profile", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
if err := pprof.StartCPUProfile(f); err != nil {
|
|
slog.Error("Failed to start profile", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
}
|
|
|
|
if err := app.Start(); err != nil {
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
|
|
cleanConfigDirectory()
|
|
|
|
if cfgWrapper.Options().StartBrowser && !c.NoBrowser && !c.InternalRestarting {
|
|
// Can potentially block if the utility we are invoking doesn't
|
|
// fork, and just execs, hence keep it in its own routine.
|
|
go func() { _ = openURL(cfgWrapper.GUI().URL()) }()
|
|
}
|
|
|
|
status := app.Wait()
|
|
|
|
if status == svcutil.ExitError {
|
|
slog.Error("Syncthing stopped with error", slogutil.Error(app.Error()))
|
|
}
|
|
|
|
if c.DebugProfileCPU {
|
|
pprof.StopCPUProfile()
|
|
}
|
|
|
|
// Best effort remove lockfile, doesn't matter if it succeeds
|
|
_ = lf.Unlock()
|
|
_ = os.Remove(locations.Get(locations.LockFile))
|
|
|
|
os.Exit(int(status))
|
|
}
|
|
|
|
func setupSignalHandling(app *syncthing.App) {
|
|
// Exit cleanly with "restarting" code on SIGHUP.
|
|
|
|
restartSign := make(chan os.Signal, 1)
|
|
signal.Notify(restartSign, syscall.SIGHUP)
|
|
go func() {
|
|
<-restartSign
|
|
app.Stop(svcutil.ExitRestart)
|
|
}()
|
|
|
|
// Exit with "success" code (no restart) on INT/TERM
|
|
|
|
stopSign := make(chan os.Signal, 1)
|
|
signal.Notify(stopSign, os.Interrupt, sigTerm)
|
|
go func() {
|
|
<-stopSign
|
|
app.Stop(svcutil.ExitSuccess)
|
|
}()
|
|
}
|
|
|
|
// loadOrDefaultConfig creates a temporary, minimal configuration wrapper if no file
|
|
// exists. As it disregards some command-line options, that should never be persisted.
|
|
func loadOrDefaultConfig() (config.Wrapper, error) {
|
|
cfgFile := locations.Get(locations.ConfigFile)
|
|
cfg, _, err := config.Load(cfgFile, protocol.EmptyDeviceID, events.NoopLogger)
|
|
if err != nil {
|
|
newCfg := config.New(protocol.EmptyDeviceID)
|
|
return config.Wrap(cfgFile, newCfg, protocol.EmptyDeviceID, events.NoopLogger), nil
|
|
}
|
|
|
|
return cfg, err
|
|
}
|
|
|
|
func auditWriter(auditFile string) io.Writer {
|
|
var fd io.Writer
|
|
var err error
|
|
var auditDest string
|
|
var auditFlags int
|
|
|
|
switch auditFile {
|
|
case "-":
|
|
fd = os.Stdout
|
|
auditDest = "stdout"
|
|
case "--":
|
|
fd = os.Stderr
|
|
auditDest = "stderr"
|
|
default:
|
|
if auditFile == "" {
|
|
auditFile = locations.GetTimestamped(locations.AuditLog)
|
|
auditFlags = os.O_WRONLY | os.O_CREATE | os.O_EXCL
|
|
} else {
|
|
auditFlags = os.O_WRONLY | os.O_CREATE | os.O_APPEND
|
|
}
|
|
fd, err = os.OpenFile(auditFile, auditFlags, 0o600)
|
|
if err != nil {
|
|
slog.Error("Failed to open audit file", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
auditDest = auditFile
|
|
}
|
|
|
|
slog.Info("Writing audit log", slogutil.FilePath(auditDest))
|
|
|
|
return fd
|
|
}
|
|
|
|
func (c *serveCmd) autoUpgradePossible() bool {
|
|
if upgrade.DisabledByCompilation {
|
|
return false
|
|
}
|
|
if c.NoUpgrade {
|
|
slog.Info("No automatic upgrades; STNOUPGRADE environment variable defined")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func autoUpgrade(cfg config.Wrapper, app *syncthing.App, evLogger events.Logger) {
|
|
timer := time.NewTimer(upgradeCheckInterval)
|
|
sub := evLogger.Subscribe(events.DeviceConnected)
|
|
for {
|
|
select {
|
|
case event := <-sub.C():
|
|
data, ok := event.Data.(map[string]string)
|
|
if !ok || data["clientName"] != "syncthing" || upgrade.CompareVersions(data["clientVersion"], build.Version) != upgrade.Newer {
|
|
continue
|
|
}
|
|
if cfg.Options().AutoUpgradeEnabled() {
|
|
slog.Info("Connected to device with a newer version; checking for upgrades", slog.String("device", data["id"]), slog.String("ourVersion", build.Version), slog.String("theirVersion", data["clientVersion"]))
|
|
}
|
|
case <-timer.C:
|
|
}
|
|
|
|
opts := cfg.Options()
|
|
if !opts.AutoUpgradeEnabled() {
|
|
timer.Reset(upgradeCheckInterval)
|
|
continue
|
|
}
|
|
|
|
checkInterval := time.Duration(opts.AutoUpgradeIntervalH) * time.Hour
|
|
rel, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
|
|
if errors.Is(err, upgrade.ErrUpgradeUnsupported) {
|
|
sub.Unsubscribe()
|
|
return
|
|
}
|
|
if err != nil {
|
|
// Don't complain too loudly here; we might simply not have
|
|
// internet connectivity, or the upgrade server might be down.
|
|
slog.Info("Automatic upgrade", slogutil.Error(err))
|
|
timer.Reset(checkInterval)
|
|
continue
|
|
}
|
|
|
|
if upgrade.CompareVersions(rel.Tag, build.Version) != upgrade.Newer {
|
|
// Skip equal, older or majorly newer (incompatible) versions
|
|
timer.Reset(checkInterval)
|
|
continue
|
|
}
|
|
|
|
slog.Info("Automatic upgrade", "current", build.Version, "latest", rel.Tag)
|
|
err = upgrade.To(rel)
|
|
if err != nil {
|
|
slog.Error("Automatic upgrade failed", slogutil.Error(err))
|
|
timer.Reset(checkInterval)
|
|
continue
|
|
}
|
|
sub.Unsubscribe()
|
|
restartDelay := time.Minute
|
|
evLogger.Log(events.UpgradeRestartScheduled, map[string]any{
|
|
"delayS": int(restartDelay / time.Second),
|
|
"newVersion": rel.Tag,
|
|
})
|
|
slog.Error("Automatically upgraded, restarting in 1 minute", slog.String("newVersion", rel.Tag))
|
|
time.Sleep(restartDelay)
|
|
app.Stop(svcutil.ExitUpgrade)
|
|
return
|
|
}
|
|
}
|
|
|
|
func initialAutoUpgradeCheck(misc *db.Typed) (upgrade.Release, error) {
|
|
if last, ok, err := misc.Time(upgradeCheckKey); err == nil && ok && time.Since(last) < upgradeCheckInterval {
|
|
return upgrade.Release{}, errTooEarlyUpgradeCheck
|
|
}
|
|
_ = misc.PutTime(upgradeCheckKey, time.Now())
|
|
release, err := checkUpgrade()
|
|
if err != nil {
|
|
return upgrade.Release{}, err
|
|
}
|
|
if upgrade.CompareVersions(release.Tag, build.Version) == upgrade.MajorNewer {
|
|
return upgrade.Release{}, errors.New("higher major version")
|
|
}
|
|
|
|
if lastVersion, ok, err := misc.String(upgradeVersionKey); err == nil && ok && lastVersion == release.Tag {
|
|
// Only check time if we try to upgrade to the same release.
|
|
if lastTime, ok, err := misc.Time(upgradeTimeKey); err == nil && ok && time.Since(lastTime) < upgradeRetryInterval {
|
|
return upgrade.Release{}, errTooEarlyUpgrade
|
|
}
|
|
}
|
|
_ = misc.PutString(upgradeVersionKey, release.Tag)
|
|
_ = misc.PutTime(upgradeTimeKey, time.Now())
|
|
return release, nil
|
|
}
|
|
|
|
// cleanConfigDirectory removes old, unused configuration and index formats, a
|
|
// suitable time after they have gone out of fashion.
|
|
func cleanConfigDirectory() {
|
|
patterns := map[string]time.Duration{
|
|
"panic-*.log": 7 * 24 * time.Hour, // keep panic logs for a week
|
|
"audit-*.log": 7 * 24 * time.Hour, // keep audit logs for a week
|
|
"index-v0.14.0.db-migrated": 14 * 24 * time.Hour, // keep old index format for two weeks
|
|
"config.xml.v*": 30 * 24 * time.Hour, // old config versions for a month
|
|
"support-bundle-*": 30 * 24 * time.Hour, // keep old support bundle zip or folder for a month
|
|
}
|
|
|
|
locations := slices.Compact([]string{
|
|
locations.GetBaseDir(locations.ConfigBaseDir),
|
|
locations.GetBaseDir(locations.DataBaseDir),
|
|
})
|
|
for _, loc := range locations {
|
|
fs := fs.NewFilesystem(fs.FilesystemTypeBasic, loc)
|
|
for pat, dur := range patterns {
|
|
entries, err := fs.Glob(pat)
|
|
if err != nil {
|
|
slog.Warn("Failed to clean config directory", slogutil.Error(err))
|
|
continue
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
info, err := fs.Lstat(entry)
|
|
if err != nil {
|
|
slog.Warn("Failed to clean config directory", slogutil.Error(err))
|
|
continue
|
|
}
|
|
|
|
if time.Since(info.ModTime()) > dur {
|
|
if err = fs.RemoveAll(entry); err != nil {
|
|
slog.Warn("Failed to clean config directory", slogutil.Error(err))
|
|
} else {
|
|
slog.Warn("Cleaned away old file", slogutil.FilePath(filepath.Base(entry)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func setPauseState(cfgWrapper config.Wrapper, paused bool) {
|
|
_, err := cfgWrapper.Modify(func(cfg *config.Configuration) {
|
|
for i := range cfg.Devices {
|
|
cfg.Devices[i].Paused = paused
|
|
}
|
|
for i := range cfg.Folders {
|
|
cfg.Folders[i].Paused = paused
|
|
}
|
|
})
|
|
if err != nil {
|
|
slog.Error("Cannot adjust paused state", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
}
|
|
|
|
func exitCodeForUpgrade(err error) int {
|
|
var noUpgradeErr *errNoUpgrade
|
|
if errors.As(err, &noUpgradeErr) {
|
|
return svcutil.ExitNoUpgradeAvailable.AsInt()
|
|
}
|
|
return svcutil.ExitError.AsInt()
|
|
}
|
|
|
|
type versionCmd struct{}
|
|
|
|
func (versionCmd) Run() error {
|
|
fmt.Println(build.LongVersion)
|
|
return nil
|
|
}
|
|
|
|
type deviceIDCmd struct{}
|
|
|
|
func (deviceIDCmd) Run() error {
|
|
cert, err := tls.LoadX509KeyPair(
|
|
locations.Get(locations.CertFile),
|
|
locations.Get(locations.KeyFile),
|
|
)
|
|
if err != nil {
|
|
slog.Error("Failed to read device ID", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
|
|
fmt.Println(protocol.NewDeviceID(cert.Certificate[0]))
|
|
return nil
|
|
}
|
|
|
|
type pathsCmd struct{}
|
|
|
|
func (pathsCmd) Run() error {
|
|
fmt.Print(locations.PrettyPaths())
|
|
return nil
|
|
}
|
|
|
|
type upgradeCmd struct {
|
|
CheckOnly bool `short:"c" help:"Check for available upgrade, then exit"`
|
|
From string `short:"u" placeholder:"URL" help:"Force upgrade directly from specified URL"`
|
|
}
|
|
|
|
func (u upgradeCmd) Run() error {
|
|
if u.CheckOnly {
|
|
if _, err := checkUpgrade(); err != nil {
|
|
slog.Error("Failed to check for upgrade", slogutil.Error(err))
|
|
os.Exit(exitCodeForUpgrade(err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if u.From != "" {
|
|
err := upgrade.ToURL(u.From)
|
|
if err != nil {
|
|
slog.Error("Failed to upgrade", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
slog.Info("Upgraded", "from", u.From)
|
|
return nil
|
|
}
|
|
|
|
release, err := checkUpgrade()
|
|
if err == nil {
|
|
lf := flock.New(locations.Get(locations.LockFile))
|
|
var locked bool
|
|
locked, err = lf.TryLock()
|
|
// ErrNotExist is a valid error if this is a new/blank installation
|
|
// without a config dir, in which case we can proceed with a normal
|
|
// non-API upgrade.
|
|
switch {
|
|
case err != nil && !os.IsNotExist(err):
|
|
slog.Error("Failed to lock for upgrade", slogutil.Error(err))
|
|
os.Exit(1)
|
|
case locked:
|
|
err = upgradeViaRest()
|
|
default:
|
|
err = upgrade.To(release)
|
|
}
|
|
}
|
|
if err != nil {
|
|
slog.Error("Failed to check for upgrade", slogutil.Error(err))
|
|
os.Exit(exitCodeForUpgrade(err))
|
|
}
|
|
slog.Info("Upgraded", "to", release.Tag)
|
|
os.Exit(svcutil.ExitUpgrade.AsInt())
|
|
return nil
|
|
}
|
|
|
|
type browserCmd struct{}
|
|
|
|
func (browserCmd) Run() error {
|
|
if err := openGUI(); err != nil {
|
|
slog.Error("Failed to open web UI", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type debugCmd struct {
|
|
ResetDatabase resetDatabaseCmd `cmd:"" help:"Reset the database, forcing a full rescan and resync"`
|
|
DatabaseStatistics databaseStatsCmd `cmd:"" help:"Display database size statistics"`
|
|
DatabaseCounts databaseCountsCmd `cmd:"" help:"Display database folder counts"`
|
|
DatabaseFile databaseFileCmd `cmd:"" help:"Display database file metadata"`
|
|
}
|
|
|
|
type resetDatabaseCmd struct{}
|
|
|
|
func (resetDatabaseCmd) Run() error {
|
|
slog.Info("Removing database", slogutil.FilePath(locations.Get(locations.Database)))
|
|
if err := os.RemoveAll(locations.Get(locations.Database)); err != nil {
|
|
slog.Error("Failed to reset database", slogutil.Error(err))
|
|
os.Exit(svcutil.ExitError.AsInt())
|
|
}
|
|
slog.Info("Reset database - it will be rebuilt after next start")
|
|
return nil
|
|
}
|
|
|
|
type databaseStatsCmd struct{}
|
|
|
|
func (c databaseStatsCmd) Run() error {
|
|
db, err := sqlite.Open(locations.Get(locations.Database))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ds, err := db.Statistics()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(os.Stdout, 2, 2, 2, ' ', 0)
|
|
hdr := fmt.Sprintf("%s\t%s\t%s\t%12s\t%7s\n", "DATABASE", "FOLDER ID", "TABLE", "SIZE", "FILL")
|
|
fmt.Fprint(tw, hdr)
|
|
fmt.Fprint(tw, regexp.MustCompile(`[A-Z]`).ReplaceAllString(hdr, "="))
|
|
c.printStat(tw, ds)
|
|
return tw.Flush()
|
|
}
|
|
|
|
type databaseCountsCmd struct {
|
|
Folder string `arg:"" required:""`
|
|
}
|
|
|
|
func (c databaseCountsCmd) Run() error {
|
|
db, err := sqlite.Open(locations.Get(locations.Database))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return db.DebugCounts(os.Stdout, c.Folder)
|
|
}
|
|
|
|
type databaseFileCmd struct {
|
|
Folder string `arg:"" required:""`
|
|
File string `arg:"" required:""`
|
|
}
|
|
|
|
func (c databaseFileCmd) Run() error {
|
|
db, err := sqlite.Open(locations.Get(locations.Database))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return db.DebugFilePattern(os.Stdout, c.Folder, c.File)
|
|
}
|
|
|
|
func (c databaseStatsCmd) printStat(w io.Writer, s *sqlite.DatabaseStatistics) {
|
|
for _, table := range s.Tables {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%8d KiB\t%5.01f %%\n", s.Name, cmp.Or(s.FolderID, "-"), table.Name, table.Size/1024, float64(table.Size-table.Unused)*100/float64(table.Size))
|
|
}
|
|
for _, next := range s.Children {
|
|
c.printStat(w, &next)
|
|
s.Total.Size += next.Total.Size
|
|
s.Total.Unused += next.Total.Unused
|
|
}
|
|
|
|
totalName := s.Name
|
|
if len(s.Children) > 0 {
|
|
totalName += " + children"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%8d KiB\t%5.01f %%\n", totalName, cmp.Or(s.FolderID, "-"), "(total)", s.Total.Size/1024, float64(s.Total.Size-s.Total.Unused)*100/float64(s.Total.Size))
|
|
}
|
|
|
|
func setConfigDataLocationsFromFlags(homeDir, confDir, dataDir string) error {
|
|
homeSet := homeDir != ""
|
|
confSet := confDir != ""
|
|
dataSet := dataDir != ""
|
|
switch {
|
|
case dataSet != confSet:
|
|
return errors.New("either both or none of --config and --data must be given, use --home to set both at once")
|
|
case homeSet && dataSet:
|
|
return errors.New("--home must not be used together with --config and --data")
|
|
case homeSet:
|
|
confDir = homeDir
|
|
dataDir = homeDir
|
|
fallthrough
|
|
case dataSet:
|
|
if err := locations.SetBaseDir(locations.ConfigBaseDir, confDir); err != nil {
|
|
return err
|
|
}
|
|
return locations.SetBaseDir(locations.DataBaseDir, dataDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type migratingAPI struct {
|
|
addr string
|
|
startDelay time.Duration
|
|
}
|
|
|
|
func (m migratingAPI) Serve(ctx context.Context) error {
|
|
srv := &http.Server{
|
|
Addr: m.addr,
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("*** Database migration in progress ***\n\n"))
|
|
for _, line := range slogutil.GlobalRecorder.Since(time.Time{}) {
|
|
_, _ = line.WriteTo(w, slogutil.DefaultLineFormat)
|
|
}
|
|
}),
|
|
}
|
|
go func() {
|
|
select {
|
|
case <-time.After(m.startDelay):
|
|
slog.InfoContext(ctx, "Starting temporary GUI/API during migration", slogutil.Address(m.addr))
|
|
err := srv.ListenAndServe()
|
|
slog.InfoContext(ctx, "Temporary GUI/API closed", slogutil.Address(m.addr), slogutil.Error(err))
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
<-ctx.Done()
|
|
return srv.Close()
|
|
}
|