package main import ( "context" "errors" "flag" "fmt" "io" "os" "os/signal" "path/filepath" "syscall" "time" _ "time/tzdata" ) var Version = "dev" const ( exitOK = 0 exitError = 1 exitUsage = 2 exitFatal = 3 usageText = `usage: devplace-node [flags] commands: login store the server and device secret (--server URL --secret dpn_...) status show the stored configuration, today's usage and whether the server accepts this device run connect and serve jobs in the foreground version print the version flags for every command: --config PATH (default: /devplace-node/config.json) ` ) func main() { os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr)) } func execute(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { fmt.Fprint(stderr, usageText) return exitUsage } command, rest := args[0], args[1:] switch command { case "version": fmt.Fprintln(stdout, "devplace-node", Version) return exitOK case "login": return runLogin(rest, stdout, stderr) case "status": return runStatus(rest, stdout, stderr) case "run": return runRun(rest, stderr) case "help", "-h", "--help": fmt.Fprint(stdout, usageText) return exitOK } fmt.Fprintf(stderr, "unknown command %q\n%s", command, usageText) return exitUsage } func resolveConfigPath(flagValue string) (string, error) { if flagValue != "" { return flagValue, nil } return DefaultConfigPath() } func newFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) { set := flag.NewFlagSet(name, flag.ContinueOnError) set.SetOutput(stderr) path := set.String("config", "", "config file path") return set, path } func runLogin(args []string, stdout, stderr io.Writer) int { set, pathFlag := newFlags("login", stderr) server := set.String("server", "", "server url, for example https://devplace.net") secret := set.String("secret", "", "device secret (or set DEVPLACE_NODE_SECRET)") bandwidth := set.Int("bandwidth-mb", DefaultBandwidthMB, "daily bandwidth cap in MB") concurrent := set.Int("concurrency", DefaultMaxConcurrent, "maximum parallel jobs") tunnels := set.Int("tunnels", DefaultMaxTunnels, "maximum parallel tunnels") from := set.String("from", "", "active hours start HH:MM") to := set.String("to", "", "active hours end HH:MM") zone := set.String("timezone", "", "IANA timezone for the active hours (default: system)") skip := set.Bool("no-verify", false, "store without asking the server to accept the secret") if err := set.Parse(args); err != nil { return exitUsage } if *secret == "" { *secret = os.Getenv("DEVPLACE_NODE_SECRET") } path, err := resolveConfigPath(*pathFlag) if err != nil { fmt.Fprintln(stderr, err) return exitError } config := Config{ Server: *server, Secret: *secret, BandwidthMBDay: *bandwidth, MaxConcurrent: *concurrent, MaxTunnels: *tunnels, ActiveFrom: *from, ActiveTo: *to, Timezone: *zone, } if err := config.Validate(); err != nil { fmt.Fprintln(stderr, "login refused:", err) return exitUsage } if !*skip { if code := verifyDevice(config, stderr); code != exitOK { return code } } if err := SaveConfig(path, config); err != nil { fmt.Fprintln(stderr, "config could not be written:", err) return exitError } fmt.Fprintf(stdout, "stored %s (mode 0600)\n", path) return exitOK } func verifyDevice(config Config, stderr io.Writer) int { session := NewSession(config, NewPolicy(), nil, nil, nil, NewLogger(stderr, false)) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(TicketTimeoutSeconds+5)*time.Second) defer cancel() _, result := session.fetchTicket(ctx) if result.fatal != "" { fmt.Fprintln(stderr, "server refused this device:", result.fatal) return exitFatal } if result.reason != "" { fmt.Fprintln(stderr, "server not verified:", result.reason, "(use --no-verify to store anyway)") return exitError } return exitOK } func runStatus(args []string, stdout, stderr io.Writer) int { set, pathFlag := newFlags("status", stderr) if err := set.Parse(args); err != nil { return exitUsage } path, err := resolveConfigPath(*pathFlag) if err != nil { fmt.Fprintln(stderr, err) return exitError } config, err := LoadConfig(path) if err != nil { fmt.Fprintln(stderr, err) return exitError } location, _ := config.Location() used := ReadUsage(filepath.Join(filepath.Dir(path), UsageFileName), location, time.Now()) fmt.Fprintf(stdout, "version: %s\nconfig: %s\nserver: %s\nsecret: stored\n", Version, path, config.Server) fmt.Fprintf(stdout, "bandwidth: %d MB/day (used today: %.1f MB)\n", config.BandwidthMBDay, float64(used)/mebibyte) fmt.Fprintf(stdout, "concurrency: %d jobs, %d tunnels\n", config.MaxConcurrent, config.MaxTunnels) hours := "always" if config.ActiveFrom != "" { hours = config.ActiveFrom + "-" + config.ActiveTo } fmt.Fprintf(stdout, "active hours: %s (%s)\n", hours, location) if code := verifyDevice(config, stderr); code != exitOK { fmt.Fprintln(stdout, "server: not accepting this device") return code } fmt.Fprintln(stdout, "server: accepts this device") return exitOK } func runRun(args []string, stderr io.Writer) int { set, pathFlag := newFlags("run", stderr) verbose := set.Bool("v", false, "verbose logging") if err := set.Parse(args); err != nil { return exitUsage } path, err := resolveConfigPath(*pathFlag) if err != nil { fmt.Fprintln(stderr, err) return exitError } config, err := LoadConfig(path) if err != nil { fmt.Fprintln(stderr, err) return exitError } logger := NewLogger(stderr, *verbose) limiter, err := NewLimiter(config, filepath.Join(filepath.Dir(path), UsageFileName), time.Now) if err != nil { fmt.Fprintln(stderr, err) return exitError } policy := NewPolicy() stats := NewStats(time.Now()) session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() logger.Printf("started version %s, server %s", Version, config.Server) err = session.Run(ctx) limiter.Flush() logger.Printf("%s", stats.Line()) var fatal *FatalError if errors.As(err, &fatal) { logger.Printf("%s", fatal.Error()) return exitFatal } if err != nil { logger.Printf("stopped: %v", err) return exitError } return exitOK }