@@ -0,0 +1,177 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user