178 lines
4.5 KiB
Go
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
|
|
}
|