package main import ( "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "regexp" "strings" "time" ) const ( DefaultBandwidthMB = 500 DefaultMaxConcurrent = 2 DefaultMaxTunnels = 2 ConfigDirName = "devplace-node" ConfigFileName = "config.json" UsageFileName = "usage.json" maxConfigBytes = 64 * 1024 ) var hoursPattern = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) type Config struct { Server string `json:"server"` Secret string `json:"secret"` BandwidthMBDay int `json:"bandwidth_mb_day"` MaxConcurrent int `json:"max_concurrent"` MaxTunnels int `json:"max_tunnels"` ActiveFrom string `json:"active_from"` ActiveTo string `json:"active_to"` Timezone string `json:"timezone"` } func DefaultConfig() Config { return Config{ BandwidthMBDay: DefaultBandwidthMB, MaxConcurrent: DefaultMaxConcurrent, MaxTunnels: DefaultMaxTunnels, } } func DefaultConfigPath() (string, error) { base, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("cannot find the user config directory: %w", err) } return filepath.Join(base, ConfigDirName, ConfigFileName), nil } func (c Config) Validate() error { parsed, err := url.Parse(c.Server) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return errors.New("server must be an http or https url such as https://devplace.net") } if !strings.HasPrefix(c.Secret, SecretPrefix) || len(c.Secret) < len(SecretPrefix)+8 { return errors.New("secret must be a device secret starting with " + SecretPrefix) } if c.BandwidthMBDay < 1 || c.BandwidthMBDay > MaxDonorMBPerDay { return fmt.Errorf("bandwidth_mb_day must be between 1 and %d", MaxDonorMBPerDay) } if c.MaxConcurrent < 1 || c.MaxConcurrent > MaxConcurrentCeiling { return fmt.Errorf("max_concurrent must be between 1 and %d", MaxConcurrentCeiling) } if c.MaxTunnels < 1 || c.MaxTunnels > MaxTunnelsCeiling { return fmt.Errorf("max_tunnels must be between 1 and %d", MaxTunnelsCeiling) } for _, value := range []string{c.ActiveFrom, c.ActiveTo} { if value != "" && !hoursPattern.MatchString(value) { return errors.New("active_from and active_to must be empty or HH:MM") } } if (c.ActiveFrom == "") != (c.ActiveTo == "") { return errors.New("active_from and active_to must be set together") } if _, err := c.Location(); err != nil { return err } return nil } func (c Config) Location() (*time.Location, error) { if c.Timezone == "" { return time.Local, nil } location, err := time.LoadLocation(c.Timezone) if err != nil { return nil, fmt.Errorf("timezone %q is not known", c.Timezone) } return location, nil } func (c Config) BaseURL() string { return strings.TrimRight(c.Server, "/") } func LoadConfig(path string) (Config, error) { file, err := os.Open(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return Config{}, fmt.Errorf("no config at %s, run: devplace-node login --server URL --secret dpn_...", path) } return Config{}, err } defer file.Close() config := DefaultConfig() decoder := json.NewDecoder(&limitedReader{r: file, left: maxConfigBytes}) decoder.DisallowUnknownFields() if err := decoder.Decode(&config); err != nil { return Config{}, fmt.Errorf("config %s is invalid: %w", path, err) } if err := config.Validate(); err != nil { return Config{}, fmt.Errorf("config %s is invalid: %w", path, err) } return config, nil } type limitedReader struct { r *os.File left int } func (l *limitedReader) Read(p []byte) (int, error) { if l.left <= 0 { return 0, errors.New("file is too large") } if len(p) > l.left { p = p[:l.left] } n, err := l.r.Read(p) l.left -= n return n, err } func SaveConfig(path string, config Config) error { if err := config.Validate(); err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } return writeFileAtomic(path, append(data, '\n')) } func writeFileAtomic(path string, data []byte) error { temp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") if err != nil { return err } name := temp.Name() if err := temp.Chmod(0o600); err != nil { temp.Close() os.Remove(name) return err } if _, err := temp.Write(data); err != nil { temp.Close() os.Remove(name) return err } if err := temp.Close(); err != nil { os.Remove(name) return err } if err := os.Rename(name, path); err != nil { os.Remove(name) return err } return nil }