verstak-sync-server/internal/server/config.go

261 lines
6.8 KiB
Go

package server
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
}
type Config struct {
// Port remains readable for older config.yml files. New deployments use
// Listen so an administrator must deliberately expose a non-loopback
// listener.
Port int `yaml:"port,omitempty"`
Listen string `yaml:"listen,omitempty"`
TrustedProxies []string `yaml:"trusted_proxies,omitempty"`
PublicURL string `yaml:"public_url,omitempty"`
DevelopmentTokenLogging bool `yaml:"development_token_logging,omitempty"`
Limits Limits `yaml:"limits,omitempty"`
Retention Retention `yaml:"retention,omitempty"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
trustedProxyPrefixes []netip.Prefix
}
// Retention controls data that has no role in reconstructing a vault. Sync
// operations and referenced blobs are deliberately absent: pruning either
// requires a checkpoint protocol or risks making a new device unrecoverable.
type Retention struct {
IdempotencyHours int `yaml:"idempotency_hours"`
AuditDays int `yaml:"audit_days"`
TempUploadHours int `yaml:"temp_upload_hours"`
}
// Limits protect the process and operation log independently of a client
// supplied value. They are deliberately conservative defaults for a
// self-hosted service and can be adjusted in config.yml.
type Limits struct {
MaxJSONBody int64 `yaml:"max_json_body"`
MaxPushOperations int `yaml:"max_push_operations"`
MaxPayloadJSON int `yaml:"max_payload_json"`
MaxPullPage int `yaml:"max_pull_page"`
MaxBlobBytes int64 `yaml:"max_blob_bytes"`
MaxVaultBlobBytes int64 `yaml:"max_vault_blob_bytes"`
MaxUserBlobBytes int64 `yaml:"max_user_blob_bytes"`
}
func defaultLimits() Limits {
return Limits{
MaxJSONBody: 2 << 20,
MaxPushOperations: 100,
MaxPayloadJSON: 256 << 10,
MaxPullPage: 100,
MaxBlobBytes: 256 << 20,
MaxVaultBlobBytes: 4 << 30,
MaxUserBlobBytes: 8 << 30,
}
}
// DefaultConfig is the safe baseline used by a new server and tests.
func DefaultConfig() *Config {
return &Config{
Port: 47732,
Listen: "127.0.0.1:47732",
Limits: defaultLimits(),
Retention: Retention{IdempotencyHours: 24, AuditDays: 90, TempUploadHours: 24},
}
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := DefaultConfig()
cfg.path = path
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
}
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("read config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// ListenAddress returns a loopback address unless an administrator explicitly
// configured another address. A legacy port-only config is also loopback.
func (c *Config) ListenAddress() string {
if c == nil {
return "127.0.0.1:47732"
}
if listen := strings.TrimSpace(c.Listen); listen != "" {
return listen
}
port := c.Port
if port == 0 {
port = 47732
}
return fmt.Sprintf("127.0.0.1:%d", port)
}
func (c *Config) normalize() error {
if c.Port == 0 {
c.Port = 47732
}
if strings.TrimSpace(c.Listen) == "" {
c.Listen = fmt.Sprintf("127.0.0.1:%d", c.Port)
}
defaults := defaultLimits()
if c.Limits.MaxJSONBody <= 0 {
c.Limits.MaxJSONBody = defaults.MaxJSONBody
}
if c.Limits.MaxPushOperations <= 0 {
c.Limits.MaxPushOperations = defaults.MaxPushOperations
}
if c.Limits.MaxPayloadJSON <= 0 {
c.Limits.MaxPayloadJSON = defaults.MaxPayloadJSON
}
if c.Limits.MaxPullPage <= 0 {
c.Limits.MaxPullPage = defaults.MaxPullPage
}
if c.Limits.MaxBlobBytes <= 0 {
c.Limits.MaxBlobBytes = defaults.MaxBlobBytes
}
if c.Limits.MaxVaultBlobBytes <= 0 {
c.Limits.MaxVaultBlobBytes = defaults.MaxVaultBlobBytes
}
if c.Limits.MaxUserBlobBytes <= 0 {
c.Limits.MaxUserBlobBytes = defaults.MaxUserBlobBytes
}
if c.Retention.IdempotencyHours <= 0 {
c.Retention.IdempotencyHours = 24
}
if c.Retention.AuditDays <= 0 {
c.Retention.AuditDays = 90
}
if c.Retention.TempUploadHours <= 0 {
c.Retention.TempUploadHours = 24
}
prefixes := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
addr, addrErr := netip.ParseAddr(raw)
if addrErr != nil {
return fmt.Errorf("trusted proxy %q is neither an IP address nor CIDR: %w", raw, err)
}
bits := 32
if addr.Is6() {
bits = 128
}
prefix = netip.PrefixFrom(addr, bits)
}
prefixes = append(prefixes, prefix.Masked())
}
c.trustedProxyPrefixes = prefixes
return nil
}
// Normalize applies safe defaults and validates proxy configuration after
// command-line and environment overrides.
func (c *Config) Normalize() error {
return c.normalize()
}
func (c *Config) isTrustedProxy(addr netip.Addr) bool {
for _, prefix := range c.trustedProxyPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.saveLocked()
}
func (c *Config) SetAdmin(username, password string) error {
c.mu.Lock()
defer c.mu.Unlock()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := AdminUser{Username: username, PasswordHash: string(hash)}
for i, u := range c.Admin {
if u.Username == username {
c.Admin[i] = user
return c.saveLocked()
}
}
c.Admin = append(c.Admin, user)
return c.saveLocked()
}
func (c *Config) CheckAdmin(username, password string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range c.Admin {
if u.Username == username {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return true
}
}
}
return false
}
func (c *Config) saveLocked() error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.path), 0750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(c.path), ".config-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0640); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, c.path)
}