Files
devplacepy/nodes/client/config.go
T
retoor 6f80a820da
DevPlace CI / test (push) Failing after 3m13s
Zo doen wijdat in oost
2026-10-16 07:20:44 +02:00

178 lines
4.5 KiB
Go

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
}