// 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 ( "context" "crypto/tls" "flag" "log" "net" "net/http" "os" "os/signal" "path/filepath" "strconv" "syscall" "time" "gitea.tzgyn.dev/TZGyn/tunnel/internal/server" ) // version is shown on the admin dashboard; override with // -ldflags "-X main.version=". var version = "dev" func envOr(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func envInt(key string, fallback int) int { if value := os.Getenv(key); value != "" { if n, err := strconv.Atoi(value); err == nil { return n } } 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") adminToken := flag.String("admin-token", envOr("ADMIN_TOKEN", ""), "bearer token for the admin dashboard at / and /api/admin/overview; empty disables the admin API") acmeDirectory := flag.String("acme-directory", envOr("ACME_DIRECTORY_URL", "https://acme-v02.api.letsencrypt.org/directory"), "ACME directory URL") acmeEmail := flag.String("acme-email", envOr("ACME_EMAIL", ""), "contact email registered with the ACME account") acmeCABundle := flag.String("acme-ca-bundle", envOr("ACME_CA_BUNDLE", ""), "extra CA roots for the ACME server's TLS (Pebble)") acmePropagation := flag.Int("acme-propagation-seconds", envInt("ACME_PROPAGATION_TIMEOUT_SECONDS", 60), "wait this long for challenge TXT records to propagate; 0 skips the check") dnsProvider := flag.String("dns-provider", envOr("DNS_PROVIDER", ""), `challenge DNS: "porkbun" (default when its keys are set) or "challtestsrv" (Pebble)`) porkbunKey := flag.String("porkbun-api-key", envOr("PORKBUN_API_KEY", ""), "Porkbun API key for DNS-01 challenges") porkbunSecret := flag.String("porkbun-secret-api-key", envOr("PORKBUN_SECRET_API_KEY", ""), "Porkbun secret API key") dnsZone := flag.String("dns-zone", envOr("DNS_ZONE", ""), "zone hosting the challenge records (default: derived from the domain)") challtestsrvURL := flag.String("challtestsrv-url", envOr("CHALLTESTSRV_URL", ""), "pebble-challtestsrv management URL for DNS_PROVIDER=challtestsrv") 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) // Certificate issuance: ACME (DNS-01) when a challenge DNS provider is // configured, the built-in local CA otherwise. issuerName := "local" var issuer server.Issuer = server.NewLocalIssuer(store) var dns server.DNSProvider switch { case *dnsProvider == "challtestsrv": dns = &server.ChallengeTestSrvDNS{BaseURL: *challtestsrvURL} case *dnsProvider == "porkbun", *dnsProvider == "" && *porkbunKey != "" && *porkbunSecret != "": if *porkbunKey == "" || *porkbunSecret == "" { log.Fatal("DNS_PROVIDER=porkbun needs PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY") } zone := *dnsZone if zone == "" { zone = server.DNSZone(*domain) } log.Printf("dns: Porkbun zone %s", zone) dns = &server.PorkbunDNS{APIKey: *porkbunKey, SecretKey: *porkbunSecret, Zone: zone} case *dnsProvider != "": log.Fatalf("unknown DNS_PROVIDER %q (want porkbun or challtestsrv)", *dnsProvider) } if dns != nil { acme, err := server.NewACMEIssuer(store, server.ACMEOptions{ DirectoryURL: *acmeDirectory, Email: *acmeEmail, CABundleFile: *acmeCABundle, DNS: dns, PropagationTimeout: time.Duration(*acmePropagation) * time.Second, }) if err != nil { log.Fatalf("acme issuer: %v", err) } issuer = acme issuerName = *acmeDirectory log.Printf("certificate issuer: ACME %s", *acmeDirectory) } else { log.Printf("certificate issuer: local CA (set PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY to use ACME)") } api := &server.API{ Store: store, Hub: hub, Domain: *domain, AdminToken: *adminToken, StartedAt: time.Now(), Version: version, Issuer: issuer, IssuerName: issuerName, } if *adminToken == "" { log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)") } else { log.Printf("admin dashboard: enabled at /") } ctx, cancel := context.WithCancel(context.Background()) defer cancel() api.StartRenewal(ctx) // 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) cancel() hub.DrainAll() ln.Close() if httpLn != nil { httpLn.Close() } time.Sleep(500 * time.Millisecond) log.Printf("bye") }