From 94bdce68099091a2f217eb0a323a86fa5324a6bf Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 25 Oct 2026 14:36:32 +0800 Subject: [PATCH] Track cmd/tunneld; scope gitignore to the built binary The bare 'tunneld' ignore pattern also matched the cmd/tunneld directory, so the server's main.go was never committed and the Coolify build failed on COPY cmd. Also update deploy docs for the Coolify/Traefik topology (API via HTTPS router, tunnel traffic via TCP SNI passthrough). --- .gitignore | 3 +- AGENTS.md | 9 +++- README.md | 37 ++++++++----- cmd/tunneld/main.go | 126 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 cmd/tunneld/main.go diff --git a/.gitignore b/.gitignore index 113fbdf..5ab0f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -tunneld +/tunneld +cmd/tunneld/tunneld .local/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index cff4f4b..69e4228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,13 @@ what we take vs. skip). Never vendor it into the build. - **Deployment**: the multi-stage `Dockerfile` builds a static non-root alpine image; verify `CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" ./cmd/tunneld` after changing build-affecting code (no Docker runtime on - the dev machine). Visitors must reach `tunneld` directly on 443/80 — never - behind a TLS-terminating proxy (Coolify's Traefik included). + the dev machine). Tunnel visitor traffic must reach the SNI listener + without TLS termination (raw TCP, or a Traefik **TCP passthrough** router); + the API domain may sit behind a TLS-terminating proxy. On a + Coolify-managed host (Traefik owns 443/80): API = app domain → container + :8080 with `HTTP_MODE=serve`; tunnel traffic = custom Traefik TCP labels + (`HostSNIRegexp` + `tls.passthrough=true`) → container :8443. See README + "Deploy on Coolify". - **macOS system curl (LibreSSL) does not send SNI for `*.localhost` names**, so `curl https://api..localhost:8443/` gets closed (the relay requires diff --git a/README.md b/README.md index dc33b11..b7d596c 100644 --- a/README.md +++ b/README.md @@ -54,23 +54,32 @@ Every flag also reads an environment variable (`tunneld -h`): ## Deploy on Coolify (or any Docker host) The repo has a multi-stage `Dockerfile` producing a static, non-root -`alpine` image. +`alpine` image. Two topologies work; both need DNS for `tunnel.example.com` +**and** `*.tunnel.example.com` pointing at the host. -1. **Create the app** from this repo (Dockerfile build pack). -2. **Environment**: set `OPENTUNNEL_DOMAIN` to your domain, e.g. - `tunnel.example.com`. -3. **DNS**: point `tunnel.example.com` and `*.tunnel.example.com` (A/AAAA - records) at the host. -4. **Ports**: map host `443` → container `8443` and host `80` → container - `8080`. Do **not** put the app behind Coolify's proxy: visitors' TLS is - routed by SNI and terminated by the clients, so a proxy that terminates - TLS cannot serve it — `tunneld` itself must own ports 443/80. -5. **Storage**: add a persistent volume on `/app/data` (the CA, server - certificate, and tunnel records live there). A named volume works out of - the box; a bind mount must be owned by uid 1000. +**A. Coolify-managed host (Traefik already owns 443/80).** The API domain is +served through Traefik (Let's Encrypt); tunnel traffic is passed through +untouched by SNI, because visitor TLS is terminated by the clients: + +- App from this repo, Dockerfile build pack, private repo → deploy key. +- Domain `https://tunnel.example.com`; `ports_exposes=8080` (Traefik routes + HTTPS → the plain-HTTP API port). +- Env: `OPENTUNNEL_DOMAIN=tunnel.example.com`, `HTTP_MODE=serve` (the API + sits behind Traefik; it must not redirect to HTTPS itself). +- Custom Traefik labels (Coolify stores them base64-encoded): + `traefik.tcp.routers.tunnel-data.entrypoints=https`, + `...rule=HostSNIRegexp(`^.+\.tunnel\.example\.com$`)`, + `...tls.passthrough=true`, + `traefik.tcp.services.tunnel-data.loadbalancer.server.port=8443`. +- Persistent volume on `/app/data`; healthcheck on `/health`. + +**B. Plain Docker host.** tunneld owns the public ports itself: map host +`443` → container `8443` and `80` → container `8080` (`HTTP_MODE=redirect` +serves `/health` on 80 and 301s the rest), volume on `/app/data`. No +TLS-terminating proxy may sit in front of the tunnel ports. Until M2 lands, certificates come from the server's built-in local CA: -clients need `--ca-cert /app/data/ca.pem` (or `OPENTUNNEL_CA_CERT`), and +clients need `--ca-cert /ca.pem` (or `OPENTUNNEL_CA_CERT`), and visitors must trust that CA. With real ACME issuance (M2) everything is publicly trusted. diff --git a/cmd/tunneld/main.go b/cmd/tunneld/main.go new file mode 100644 index 0000000..f4d00fb --- /dev/null +++ b/cmd/tunneld/main.go @@ -0,0 +1,126 @@ +// tunneld is a single-node, self-hosted OpenTunnel server: it speaks the +// wire protocol from docs/protocol.md, so the official opentunnel CLI and +// SDK work against it unchanged. +package main + +import ( + "crypto/tls" + "flag" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "gitea.tzgyn.dev/TZGyn/tunnel/internal/server" +) + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func defaultDataDir() string { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "tunneld") + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".local", "share", "tunneld") + } + return ".local" +} + +func main() { + dataDefault := filepath.Join(defaultDataDir(), "data.json") + + domain := flag.String("domain", envOr("OPENTUNNEL_DOMAIN", "localhost"), "API domain; tunnels are .") + tlsListen := flag.String("tls-listen", envOr("TLS_LISTEN", "127.0.0.1:8443"), "public TLS listen address (SNI router)") + httpListen := flag.String("http-listen", envOr("HTTP_LISTEN", "127.0.0.1:8080"), `HTTP listen address ("off" disables)`) + httpMode := flag.String("http-mode", envOr("HTTP_MODE", "serve"), `"serve" runs the whole app on plain HTTP, "redirect" 301s to the domain`) + dataPath := flag.String("data", envOr("TUNNELD_DATA", dataDefault), "tunnel store file (created 0600)") + caFile := flag.String("ca-file", envOr("LOCAL_CA_FILE", ""), "write the local CA certificate here for clients to trust") + flag.Parse() + + if *caFile == "" { + *caFile = filepath.Join(filepath.Dir(*dataPath), "ca.pem") + } + + store, err := server.OpenStore(*dataPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + if err := os.MkdirAll(filepath.Dir(*caFile), 0o700); err != nil { + log.Fatalf("ca file directory: %v", err) + } + if err := os.WriteFile(*caFile, []byte(store.CACertPEM()), 0o644); err != nil { + log.Fatalf("write CA file: %v", err) + } + certPEM, keyPEM, err := store.ServerCertificate(*domain) + if err != nil { + log.Fatalf("server certificate: %v", err) + } + serverCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + log.Fatalf("parse server certificate: %v", err) + } + + hub := server.NewHub(store) + api := &server.API{Store: store, Hub: hub, Domain: *domain} + + // Public TLS listener: SNI routing, with API terminations handed to the + // shared HTTP server. + ln, err := net.Listen("tcp", *tlsListen) + if err != nil { + log.Fatalf("tls listen: %v", err) + } + apiListener := server.NewChanListener(64, ln.Addr()) + relay := &server.Relay{ + Domain: *domain, + Store: store, + Hub: hub, + Server: &serverCert, + APIConns: apiListener.Conns(), + } + go relay.Serve(ln) + go func() { + if err := http.Serve(apiListener, api); err != nil { + log.Printf("api over tls: %v", err) + } + }() + log.Printf("tls: listening on %s (domain %s)", ln.Addr(), *domain) + + var httpLn net.Listener + if *httpListen != "off" { + httpLn, err = net.Listen("tcp", *httpListen) + if err != nil { + log.Fatalf("http listen: %v", err) + } + var handler http.Handler = api + if *httpMode == "redirect" { + handler = server.RedirectHandler(*domain) + } + go func() { + if err := http.Serve(httpLn, handler); err != nil { + log.Printf("http: %v", err) + } + }() + log.Printf("http: listening on %s (mode %s)", httpLn.Addr(), *httpMode) + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + sig := <-signals + log.Printf("received %s; draining bridges", sig) + hub.DrainAll() + ln.Close() + if httpLn != nil { + httpLn.Close() + } + time.Sleep(500 * time.Millisecond) + log.Printf("bye") +}