diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ec61af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git/ +tmp/ +ublproxy +*.test +.playwright-cli/ +mise.local.toml diff --git a/DECISIONS.md b/DECISIONS.md index 5aa70db..37765eb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -51,3 +51,5 @@ - 2026-02-26 m+git@andri.dk — Session map extended from `IP→token` to `IP→{Token, CredentialID}`. The credential ID identifies which user is on which IP, enabling per-user rule lookup at all proxy layers (CONNECT, HTTP, element hiding injection). - 2026-02-26 m+git@andri.dk — `--default-subscription` CLI flag (repeatable). Defaults to EasyList + EasyPrivacy if none specified. These are loaded into the baseline at startup and apply to all traffic. Users cannot disable the baseline, only add personal rules/exceptions on top. - 2026-02-26 m+git@andri.dk — Baseline rules loaded synchronously at startup (`reloadBaseline()` called after DB setup). Ensures rules are ready before any traffic arrives. Remote subscriptions use DB cache with 24h TTL, so subsequent starts are fast. +- 2026-02-27 m+git@andri.dk — Switched CLI flag parsing from stdlib `flag` to `github.com/urfave/cli/v3` (zero production dependencies). Every flag now has a corresponding `UBLPROXY_*` environment variable via `Sources: cli.EnvVars(...)`, making the proxy Docker-friendly without an entrypoint wrapper script. Removed the custom `stringSlice` type — urfave/cli has built-in `StringSliceFlag` for repeatable flags. +- 2026-02-27 m+git@andri.dk — Added multi-stage Dockerfile. Build stage uses `golang:1.25-alpine` with `CGO_ENABLED=0`. Runtime stage uses `alpine:latest` (~5MB) with system CA certificates for outgoing HTTPS. Data directory mounted at `/data` holds the CA cert/key and SQLite database. Ports 8080/8443 are configurable via `UBLPROXY_HTTP_PORT`/`UBLPROXY_HTTPS_PORT` env vars or CLI flags. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6eb64a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /ublproxy . + +FROM alpine:latest +RUN apk add --no-cache ca-certificates +RUN addgroup -S ublproxy && adduser -S -G ublproxy ublproxy +COPY --from=build /ublproxy /usr/local/bin/ublproxy + +EXPOSE 8080 8443 +VOLUME /data +RUN mkdir -p /data && chown ublproxy:ublproxy /data + +ENV UBLPROXY_CA_DIR=/data +ENV UBLPROXY_DB=/data/ublproxy.db + +USER ublproxy +ENTRYPOINT ["ublproxy"] diff --git a/go.mod b/go.mod index 0676df3..eb1cec3 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/andybalholm/brotli v1.2.0 github.com/fxamacker/cbor/v2 v2.9.0 + github.com/urfave/cli/v3 v3.6.2 golang.org/x/net v0.51.0 modernc.org/sqlite v1.46.1 ) diff --git a/go.sum b/go.sum index 615e776..3bf249f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -14,8 +16,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -33,6 +41,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/main.go b/main.go index 035a327..5cf1d26 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,19 @@ package main import ( - "flag" + "context" "fmt" "net" "net/http" "os" "path/filepath" - "strings" + + "github.com/urfave/cli/v3" "ublproxy/pkg/store" "ublproxy/pkg/webauthn" ) -// stringSlice implements flag.Value to support repeated CLI flags. -type stringSlice []string - -func (s *stringSlice) String() string { return strings.Join(*s, ", ") } -func (s *stringSlice) Set(v string) error { *s = append(*s, v); return nil } - func main() { home, err := os.UserHomeDir() if err != nil { @@ -28,33 +23,86 @@ func main() { defaultDataDir := filepath.Join(home, ".ublproxy") - addr := flag.String("addr", "0.0.0.0", "address to listen on (0.0.0.0 for all interfaces)") - httpPort := flag.Int("http-port", 8080, "HTTP port for setup page and CA certificate download") - httpsPort := flag.Int("https-port", 8443, "HTTPS port for proxy, portal, and API") - hostname := flag.String("hostname", "localhost", "portal hostname for WebAuthn and TLS cert (must be a domain, not an IP)") - caDir := flag.String("ca-dir", defaultDataDir, "directory for CA certificate and key") - dbPath := flag.String("db", filepath.Join(defaultDataDir, "ublproxy.db"), "path to SQLite database") - - var blocklistSources stringSlice - flag.Var(&blocklistSources, "blocklist", "path or URL to a blocklist file (can be specified multiple times)") + cmd := &cli.Command{ + Name: "ublproxy", + Usage: "Ad-blocking HTTPS proxy with WebAuthn authentication", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "addr", + Value: "0.0.0.0", + Usage: "address to listen on (0.0.0.0 for all interfaces)", + Sources: cli.EnvVars("UBLPROXY_ADDR"), + }, + &cli.IntFlag{ + Name: "http-port", + Value: 8080, + Usage: "HTTP port for setup page and CA certificate download", + Sources: cli.EnvVars("UBLPROXY_HTTP_PORT"), + }, + &cli.IntFlag{ + Name: "https-port", + Value: 8443, + Usage: "HTTPS port for proxy, portal, and API", + Sources: cli.EnvVars("UBLPROXY_HTTPS_PORT"), + }, + &cli.StringFlag{ + Name: "hostname", + Value: "localhost", + Usage: "portal hostname for WebAuthn and TLS cert (must be a domain, not an IP)", + Sources: cli.EnvVars("UBLPROXY_HOSTNAME"), + }, + &cli.StringFlag{ + Name: "ca-dir", + Value: defaultDataDir, + Usage: "directory for CA certificate and key", + Sources: cli.EnvVars("UBLPROXY_CA_DIR"), + }, + &cli.StringFlag{ + Name: "db", + Value: filepath.Join(defaultDataDir, "ublproxy.db"), + Usage: "path to SQLite database", + Sources: cli.EnvVars("UBLPROXY_DB"), + }, + &cli.StringSliceFlag{ + Name: "blocklist", + Usage: "path or URL to a blocklist file (can be specified multiple times)", + Sources: cli.EnvVars("UBLPROXY_BLOCKLIST"), + }, + &cli.StringSliceFlag{ + Name: "default-subscription", + Usage: "default blocklist subscription URL, always active for all users (can be specified multiple times; defaults to EasyList + EasyPrivacy if none specified)", + Sources: cli.EnvVars("UBLPROXY_DEFAULT_SUBSCRIPTION"), + }, + }, + Action: run, + } - var defaultSubs stringSlice - flag.Var(&defaultSubs, "default-subscription", "default blocklist subscription URL, always active for all users (can be specified multiple times; defaults to EasyList + EasyPrivacy if none specified)") + if err := cmd.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} - flag.Parse() +func run(_ context.Context, cmd *cli.Command) error { + addr := cmd.String("addr") + httpPort := cmd.Int("http-port") + httpsPort := cmd.Int("https-port") + hostname := cmd.String("hostname") + caDir := cmd.String("ca-dir") + dbPath := cmd.String("db") + blocklistSources := cmd.StringSlice("blocklist") - // Built-in defaults if no --default-subscription flags were given + defaultSubs := cmd.StringSlice("default-subscription") if len(defaultSubs) == 0 { - defaultSubs = stringSlice{ + defaultSubs = []string{ "https://easylist.to/easylist/easylist.txt", "https://easylist.to/easylist/easyprivacy.txt", } } - caCert, caKey, err := loadOrGenerateCA(*caDir) + caCert, caKey, err := loadOrGenerateCA(caDir) if err != nil { - fmt.Fprintf(os.Stderr, "CA setup failed: %v\n", err) - os.Exit(1) + return fmt.Errorf("CA setup failed: %w", err) } certs := newCertCache(caCert, caKey) @@ -63,20 +111,16 @@ func main() { handler.blocklistSources = blocklistSources handler.defaultSubscriptions = defaultSubs - // Open SQLite database for credential/session/rule storage - db, err := store.Open(*dbPath) + db, err := store.Open(dbPath) if err != nil { - fmt.Fprintf(os.Stderr, "database setup failed: %v\n", err) - os.Exit(1) + return fmt.Errorf("database setup failed: %w", err) } defer db.Close() handler.store = db - // Configure WebAuthn with the portal hostname. WebAuthn requires a - // domain name as RP ID — IP addresses are not allowed by the spec. - portalOrigin := fmt.Sprintf("https://%s:%d", *hostname, *httpsPort) + portalOrigin := fmt.Sprintf("https://%s:%d", hostname, httpsPort) webauthnCfg := webauthn.Config{ - RPID: *hostname, + RPID: hostname, RPName: "ublproxy", RPOrigin: portalOrigin, } @@ -88,34 +132,26 @@ func main() { handler.sessions = sm handler.portalOrigin = portalOrigin - // Load baseline rules (--blocklist + --default-subscription) at startup. - // This runs synchronously so rules are ready before traffic arrives. handler.reloadBaseline() - // Auto-detect LAN IP for the portal TLS cert so it covers both the - // hostname and the LAN IP (useful for CA cert download, etc.) var extraIPs []net.IP if lanIP := detectLANIP(); lanIP != "" { extraIPs = append(extraIPs, net.ParseIP(lanIP)) } - // Start HTTPS proxy+portal server in a goroutine. This handles - // proxy CONNECT/forward, the management portal, and the API. - httpsAddr := fmt.Sprintf("%s:%d", *addr, *httpsPort) + httpsAddr := fmt.Sprintf("%s:%d", addr, httpsPort) portalH := &portalHandler{proxy: handler, api: api} - go startPortalHTTPS(httpsAddr, *hostname, extraIPs, certs, portalH) + go startPortalHTTPS(httpsAddr, hostname, extraIPs, certs, portalH) - // HTTP server serves only the setup page and CA certificate download. - // No proxy, no API — those require TLS on the HTTPS port. - httpAddr := fmt.Sprintf("%s:%d", *addr, *httpPort) + httpAddr := fmt.Sprintf("%s:%d", addr, httpPort) setupH := &setupHandler{caCertPEM: caCertPEM, portalOrigin: portalOrigin} fmt.Fprintf(os.Stderr, "ublproxy setup page on http://%s\n", httpAddr) fmt.Fprintf(os.Stderr, "ublproxy proxy+portal on %s\n", portalOrigin) if err := http.ListenAndServe(httpAddr, setupH); err != nil { - fmt.Fprintf(os.Stderr, "server error: %v\n", err) - os.Exit(1) + return fmt.Errorf("server error: %w", err) } + return nil } // detectLANIP returns the first non-loopback IPv4 address found on a network