// Package config holds the server's runtime options and the flag plumbing that // populates them. package config import ( "fmt" "net" "net/url" "strconv" "strings" "time" ) // Config is the fully-resolved server configuration. type Config struct { Listen string Port int // overrides the port in Listen when set DataDir string BasePath string // normalised: always "/" or "/prefix/" PublicURL string // absolute origin+path for generated links; "" => relative // SourceURL is linked in the footer. It defaults to where this program is // developed, and a fork should point it at its own; setting it empty drops // the footer entirely, for an instance that would rather not advertise. SourceURL string MaxSize int64 // per-upload cap; Unlimited means no cap MaxExpiry time.Duration // longest lifetime a caller may request DefaultExpiry time.Duration // lifetime when the caller does not ask MaxTotalBytes int64 // whole-store quota MinFreeBytes int64 // refuse uploads below this much free disk TokensPath string TrustedProxy string // comma-separated CIDRs allowed to set X-Forwarded-For SweepInterval time.Duration UploadRate int // uploads per hour per client UploadBurst int MaxConcurrent int trustedProxyNets []*net.IPNet } const EnvPrefix = "UNCENSORED_SEND_" // defaultSourceURL is where this program is developed. Anyone running a // modified copy should say so with --source-url, and anyone who would rather // not mention it at all can pass an empty one. const defaultSourceURL = "https://git.uncensored.hu/thayol/uncensored-send" // Register wires every option onto s. Short forms exist only for the options // reached often; everything else is long-only, by design. func (c *Config) Register(s *Set) { s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR", "address to listen on; keep it on loopback behind a reverse proxy") s.Int(&c.Port, "port", "p", 0, "port to listen on, replacing the one in --listen") s.String(&c.DataDir, "data", "d", "./data", "DIR", "directory holding uploaded objects and their metadata") s.String(&c.BasePath, "base-url", "b", "/", "PATH", "path prefix this service is mounted under") s.String(&c.PublicURL, "public-url", "u", "", "URL", "absolute base URL used in generated links; relative links when empty") s.String(&c.SourceURL, "source-url", "", defaultSourceURL, "URL", "where the footer's source link points; empty hides the footer") s.Size(&c.MaxSize, "max-size", "s", "2GiB", "largest upload accepted from an anonymous caller") s.Duration(&c.MaxExpiry, "max-expiry", "e", "3d", "longest lifetime an anonymous caller may request") s.Duration(&c.DefaultExpiry, "default-expiry", "", "3d", "lifetime applied when the caller does not ask for one") s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited", "refuse uploads once stored data exceeds this total") s.Size(&c.MinFreeBytes, "min-free-bytes", "", "10GiB", "refuse uploads when the filesystem has less free space than this") s.String(&c.TokensPath, "tokens", "", "", "FILE", "token file location (default /tokens.json)") s.String(&c.TrustedProxy, "trusted-proxy", "", "", "CIDRS", "comma-separated networks whose X-Forwarded-For header is believed") s.Duration(&c.SweepInterval, "sweep-interval", "", "1m", "how often expired objects are swept from disk") s.Int(&c.UploadRate, "upload-rate", "", 60, "uploads permitted per hour per client address") s.Int(&c.UploadBurst, "upload-burst", "", 10, "uploads permitted back-to-back before the rate applies") s.Int(&c.MaxConcurrent, "max-concurrent", "", 8, "uploads allowed to be in flight at once") } // Normalise validates interdependent options and canonicalises the derived // ones. It must be called after parsing and before the config is used. func (c *Config) Normalise() error { c.BasePath = NormalisePath(c.BasePath) // --port is a convenience over --listen: it replaces only the port, so the // host stays wherever --listen (or its default) put it. if c.Port != 0 { if c.Port < 1 || c.Port > 65535 { return fmt.Errorf("--port: %d is not a port number", c.Port) } host, _, err := net.SplitHostPort(c.Listen) if err != nil { // --listen held a bare host, which is fine once a port is supplied. host = strings.TrimSpace(c.Listen) } c.Listen = net.JoinHostPort(host, strconv.Itoa(c.Port)) } if _, _, err := net.SplitHostPort(c.Listen); err != nil { return fmt.Errorf("--listen: %q is not an address:port (use --port to set just the port)", c.Listen) } if c.PublicURL != "" { u, err := url.Parse(c.PublicURL) if err != nil { return fmt.Errorf("--public-url: %w", err) } if !u.IsAbs() { return fmt.Errorf("--public-url: %q is not absolute", c.PublicURL) } c.PublicURL = strings.TrimSuffix(u.String(), "/") } if c.SourceURL != "" { u, err := url.Parse(c.SourceURL) if err != nil { return fmt.Errorf("--source-url: %w", err) } if !u.IsAbs() { return fmt.Errorf("--source-url: %q is not absolute", c.SourceURL) } c.SourceURL = u.String() } if c.TokensPath == "" { c.TokensPath = c.DataDir + "/tokens.json" } if c.MaxExpiry != Unlimited && (c.DefaultExpiry == Unlimited || c.DefaultExpiry > c.MaxExpiry) { return fmt.Errorf("--default-expiry (%s) exceeds --max-expiry (%s)", FormatDuration(c.DefaultExpiry), FormatDuration(c.MaxExpiry)) } if c.SweepInterval <= 0 { return fmt.Errorf("--sweep-interval must be positive") } if c.MaxConcurrent < 1 { return fmt.Errorf("--max-concurrent must be at least 1") } for _, cidr := range strings.Split(c.TrustedProxy, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } // Accept both a bare address and a network. if ip := net.ParseIP(cidr); ip != nil { bits := 32 if ip.To4() == nil { bits = 128 } c.trustedProxyNets = append(c.trustedProxyNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) continue } _, n, err := net.ParseCIDR(cidr) if err != nil { return fmt.Errorf("--trusted-proxy: %w", err) } c.trustedProxyNets = append(c.trustedProxyNets, n) } return nil } // TrustsProxy reports whether X-Forwarded-For from ip should be believed. func (c *Config) TrustsProxy(ip net.IP) bool { for _, n := range c.trustedProxyNets { if n.Contains(ip) { return true } } return false } // NormalisePath canonicalises a mount prefix to "/" or "/prefix/". func NormalisePath(p string) string { p = strings.Trim(strings.TrimSpace(p), "/") if p == "" { return "/" } return "/" + p + "/" }