~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
259 lines · 6.9 KB Raw History
package guestagent

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/netip"
	"net/url"
	"os"
	"path/filepath"
	"strings"
)

const (
	DefaultConfigDir = "/etc/socialweb-guest-agent"

	DefaultStateDir = "/run/socialweb-guest-agent"

	DefaultLoginHelper = "/usr/local/bin/guest-login.sh"

	TunnelsFile = "tunnels.json"

	SecretsFile = "secrets.json"

	AgentFile = "agent.json"

	AssociationFile = "association.json"

	RequesterDIDFile = "requester-did"

	AssociationRequestFile = "association-request.json"

	AssociationRefFile = "association-ref"

	RequesterAtircEnvFile = "r2-atirc.env"

	RequesterGroup = "socialweb-r2"

	RequesterDir = "/run/socialweb-r2"

	RequesterStartEnvFile = "r2-start.env"

	RequesterUnit = "socialweb-r2.service"

	RequesterReleaseUnit = "socialweb-r2-release.service"

	DefaultSecretsFetchScript = "/usr/local/bin/setup-secrets.sh"

	SecretsReadyFile = "secrets.ready"

	SecretsRequestFile = "secrets.request"

	secretsTag = "secrets"

	secretsFallbackSealed = "sealed"

	BidderFile = "bidder.json"

	CursorFile = "cursor"

	STUNServer = "stun:stun.cloudflare.com:3478"

	maxTag = 32

	associationRefDir = "/etc/socialweb-computer"
)

var ErrConfig = errors.New("guestagent: invalid config")

type Tunnel struct {
	Tag string `json:"tag"`

	Port int `json:"port"`
}

type Bidder struct {
	Base string `json:"base"`

	Audience string `json:"audience"`

	AcceptPath string `json:"acceptPath"`

	WebRTC *WebRTCPort `json:"webrtc,omitempty"`
}

type WebRTCPort struct {
	UDPPort int `json:"udpPort"`

	HostAddresses []string `json:"hostAddresses"`
}

type Secrets struct {
	Fallback string `json:"fallback"`

	GetSecrets bool `json:"getSecrets"`
}

type Association struct {
	RefFile string `json:"refFile"`
}

type AcceptRef struct {
	URI string

	CID string
}

type Config struct {
	Tunnels []Tunnel

	Secrets *Secrets

	AgentUnit string

	Association *Association

	Bidder Bidder

	Accept AcceptRef

	StateDir string

	LoginHelper string
}

func (c Config) Port(tag string) (int, bool) {
	for _, t := range c.Tunnels {
		if t.Tag == tag {
			return t.Port, true
		}
	}
	return 0, false
}

func LoadConfig(configDir, stateDir, loginHelper string) (Config, error) {
	cfg := Config{StateDir: stateDir, LoginHelper: loginHelper}
	var tunnels struct {
		Tunnels []Tunnel `json:"tunnels"`
	}
	if err := readOptionalJSON(filepath.Join(configDir, TunnelsFile), &tunnels); err != nil {
		return cfg, err
	}
	var secrets Secrets
	switch err := readOptionalJSON(filepath.Join(configDir, SecretsFile), &secrets); {
	case err != nil:
		return cfg, err
	case secrets.Fallback == secretsFallbackSealed:
		cfg.Secrets = &secrets
	case secrets.Fallback != "":
		return cfg, fmt.Errorf("%w: %s fallback %q is not %s", ErrConfig, SecretsFile, secrets.Fallback, secretsFallbackSealed)
	}
	var agent AgentUnit
	if err := readOptionalJSON(filepath.Join(configDir, AgentFile), &agent); err != nil {
		return cfg, err
	}
	if agent.Unit != "" && !agentUnitRE.MatchString(agent.Unit) {
		return cfg, fmt.Errorf("%w: %s unit %q is not a systemd service name of [a-z0-9-]", ErrConfig, AgentFile, agent.Unit)
	}
	cfg.AgentUnit = agent.Unit
	var association Association
	switch err := readOptionalJSON(filepath.Join(configDir, AssociationFile), &association); {
	case err != nil:
		return cfg, err
	case association.RefFile != "":
		clean := filepath.Clean(association.RefFile)
		if !strings.HasPrefix(association.RefFile, "/") || clean != association.RefFile || !strings.HasPrefix(clean, associationRefDir+"/") {
			return cfg, fmt.Errorf("%w: %s refFile %q is not a clean path under %s/", ErrConfig, AssociationFile, association.RefFile, associationRefDir)
		}
		cfg.Association = &association
	}
	seen := map[string]bool{}
	for _, t := range tunnels.Tunnels {
		switch {
		case t.Tag == "" || len(t.Tag) > maxTag || !printable(t.Tag):
			return cfg, fmt.Errorf("%w: tunnel tag %q is not 1 to %d printable ASCII bytes", ErrConfig, t.Tag, maxTag)
		case t.Port < 1 || t.Port > 65535:
			return cfg, fmt.Errorf("%w: tunnel %s port %d is not a TCP port", ErrConfig, t.Tag, t.Port)
		case seen[t.Tag]:
			return cfg, fmt.Errorf("%w: tunnel tag %s appears twice", ErrConfig, t.Tag)
		case t.Tag == secretsTag:
			return cfg, fmt.Errorf("%w: tunnel tag %s names the secrets key", ErrConfig, t.Tag)
		}
		seen[t.Tag] = true
		cfg.Tunnels = append(cfg.Tunnels, t)
	}
	if len(cfg.Tunnels) == 0 && cfg.Secrets == nil && cfg.AgentUnit == "" && cfg.Association == nil {
		return cfg, fmt.Errorf("%w: %s names no tunnel, %s asks for no sealed secrets fallback, %s names no unit and %s names no ref file", ErrConfig,
			TunnelsFile, SecretsFile, AgentFile, AssociationFile)
	}
	if err := readJSON(filepath.Join(configDir, BidderFile), &cfg.Bidder); err != nil {
		return cfg, err
	}
	base, err := url.Parse(strings.TrimSpace(cfg.Bidder.Base))
	if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
		return cfg, fmt.Errorf("%w: %s base %q is not an http or https URL", ErrConfig, BidderFile, cfg.Bidder.Base)
	}
	cfg.Bidder.Base = strings.TrimRight(base.String(), "/")
	cfg.Bidder.Audience = strings.TrimSpace(cfg.Bidder.Audience)
	if cfg.Bidder.Audience == "" {
		return cfg, fmt.Errorf("%w: %s has no audience", ErrConfig, BidderFile)
	}
	if strings.TrimSpace(cfg.Bidder.AcceptPath) == "" {
		return cfg, fmt.Errorf("%w: %s has no acceptPath", ErrConfig, BidderFile)
	}
	if w := cfg.Bidder.WebRTC; w != nil {
		if w.UDPPort < 1 || w.UDPPort > 65535 || len(w.HostAddresses) == 0 {
			return cfg, fmt.Errorf("%w: %s webrtc names port %d and %d host addresses", ErrConfig, BidderFile, w.UDPPort, len(w.HostAddresses))
		}
		for _, a := range w.HostAddresses {
			if _, err := netip.ParseAddr(strings.TrimSpace(a)); err != nil {
				return cfg, fmt.Errorf("%w: %s webrtc host address %q is not an IP address", ErrConfig, BidderFile, a)
			}
		}
	}
	var accept struct {
		Accept struct {
			URI string `json:"uri"`
			CID string `json:"cid"`
		} `json:"accept"`
	}
	if err := readJSON(cfg.Bidder.AcceptPath, &accept); err != nil {
		return cfg, err
	}
	cfg.Accept = AcceptRef{URI: strings.TrimSpace(accept.Accept.URI), CID: strings.TrimSpace(accept.Accept.CID)}
	if cfg.Accept.URI == "" || cfg.Accept.CID == "" {
		return cfg, fmt.Errorf("%w: %s has no accept ref", ErrConfig, cfg.Bidder.AcceptPath)
	}
	if strings.TrimSpace(cfg.StateDir) == "" || strings.TrimSpace(cfg.LoginHelper) == "" {
		return cfg, fmt.Errorf("%w: a state dir and a login helper are required", ErrConfig)
	}
	return cfg, nil
}

func readOptionalJSON(path string, into any) error {
	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
		return nil
	}
	return readJSON(path, into)
}

func readJSON(path string, into any) error {
	raw, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrConfig, err)
	}
	if err := json.Unmarshal(raw, into); err != nil {
		return fmt.Errorf("%w: %s: %v", ErrConfig, path, err)
	}
	return nil
}

func printable(s string) bool {
	for i := 0; i < len(s); i++ {
		if s[i] <= ' ' || s[i] > '~' {
			return false
		}
	}
	return true
}