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).
This commit is contained in:
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
tunneld
|
/tunneld
|
||||||
|
cmd/tunneld/tunneld
|
||||||
.local/
|
.local/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
- **Deployment**: the multi-stage `Dockerfile` builds a static non-root
|
||||||
alpine image; verify `CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"
|
alpine image; verify `CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"
|
||||||
./cmd/tunneld` after changing build-affecting code (no Docker runtime on
|
./cmd/tunneld` after changing build-affecting code (no Docker runtime on
|
||||||
the dev machine). Visitors must reach `tunneld` directly on 443/80 — never
|
the dev machine). Tunnel visitor traffic must reach the SNI listener
|
||||||
behind a TLS-terminating proxy (Coolify's Traefik included).
|
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**,
|
- **macOS system curl (LibreSSL) does not send SNI for `*.localhost` names**,
|
||||||
so `curl https://api.<id>.localhost:8443/` gets closed (the relay requires
|
so `curl https://api.<id>.localhost:8443/` gets closed (the relay requires
|
||||||
|
|||||||
@@ -54,23 +54,32 @@ Every flag also reads an environment variable (`tunneld -h`):
|
|||||||
## Deploy on Coolify (or any Docker host)
|
## Deploy on Coolify (or any Docker host)
|
||||||
|
|
||||||
The repo has a multi-stage `Dockerfile` producing a static, non-root
|
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).
|
**A. Coolify-managed host (Traefik already owns 443/80).** The API domain is
|
||||||
2. **Environment**: set `OPENTUNNEL_DOMAIN` to your domain, e.g.
|
served through Traefik (Let's Encrypt); tunnel traffic is passed through
|
||||||
`tunnel.example.com`.
|
untouched by SNI, because visitor TLS is terminated by the clients:
|
||||||
3. **DNS**: point `tunnel.example.com` and `*.tunnel.example.com` (A/AAAA
|
|
||||||
records) at the host.
|
- App from this repo, Dockerfile build pack, private repo → deploy key.
|
||||||
4. **Ports**: map host `443` → container `8443` and host `80` → container
|
- Domain `https://tunnel.example.com`; `ports_exposes=8080` (Traefik routes
|
||||||
`8080`. Do **not** put the app behind Coolify's proxy: visitors' TLS is
|
HTTPS → the plain-HTTP API port).
|
||||||
routed by SNI and terminated by the clients, so a proxy that terminates
|
- Env: `OPENTUNNEL_DOMAIN=tunnel.example.com`, `HTTP_MODE=serve` (the API
|
||||||
TLS cannot serve it — `tunneld` itself must own ports 443/80.
|
sits behind Traefik; it must not redirect to HTTPS itself).
|
||||||
5. **Storage**: add a persistent volume on `/app/data` (the CA, server
|
- Custom Traefik labels (Coolify stores them base64-encoded):
|
||||||
certificate, and tunnel records live there). A named volume works out of
|
`traefik.tcp.routers.tunnel-data.entrypoints=https`,
|
||||||
the box; a bind mount must be owned by uid 1000.
|
`...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:
|
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 <data dir>/ca.pem` (or `OPENTUNNEL_CA_CERT`), and
|
||||||
visitors must trust that CA. With real ACME issuance (M2) everything is
|
visitors must trust that CA. With real ACME issuance (M2) everything is
|
||||||
publicly trusted.
|
publicly trusted.
|
||||||
|
|
||||||
|
|||||||
@@ -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 <id>.<domain>")
|
||||||
|
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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user