Files
tunnel/cmd/tunneld/main.go
T
TZGyn ebae420ab1 Add the native Go client and finish M3 hardening
The new cmd/tunnel client keeps the API URL and tunnel identity in
~/.config/tunnel/config.json (0600), provisions the key/CSR and
certificate, terminates TLS, proxies to local routes, reloads routes
without a restart, and runs as a background service. `tunnel import`
takes over an existing official CLI profile, preserving its tunnel ID,
token, key and certificate.

Server hardening: per-IP and per-tunnel rate limits for creates and
binds, a CA cool-down after repeated ACME failures, a startup sweep for
stale _acme-challenge records, admin metrics, a 60-second half-close
timeout, and separate prioritized control / ordered data bridge queues
(with `end` ordered after the final bytes). The dashboard shows the new
metrics.
2026-10-26 00:32:31 +08:00

231 lines
8.4 KiB
Go

// 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=<v>".
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 <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")
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")
createLimit := flag.Int("create-limit-per-hour", envInt("TUNNEL_CREATE_LIMIT_PER_HOUR", 60), "tunnels one client address may create per hour; 0 disables")
bindLimit := flag.Int("bind-limit-per-hour", envInt("CERT_BIND_LIMIT_PER_HOUR", 30), "new certificate binds one client address may request per hour; 0 disables")
bindTunnelLimit := flag.Int("bind-limit-per-tunnel-per-hour", envInt("CERT_BIND_LIMIT_PER_TUNNEL_PER_HOUR", 10), "new certificate binds one tunnel may request per hour; 0 disables")
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 sweeper, ok := dns.(server.ChallengeSweeper); ok {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
count, err := sweeper.SweepChallenges(ctx)
cancel()
if err != nil {
log.Printf("dns: stale challenge sweep removed %d record(s), then failed: %v", count, err)
} else if count > 0 {
log.Printf("dns: removed %d stale challenge record(s)", count)
}
}
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,
CreateLimiter: server.NewRateLimiter(*createLimit, time.Hour),
BindLimiter: server.NewRateLimiter(*bindLimit, time.Hour),
BindTunnelLimiter: server.NewRateLimiter(*bindTunnelLimit, time.Hour),
}
if *adminToken == "" {
log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)")
} else {
log.Printf("admin dashboard: enabled at /")
}
if *createLimit > 0 || *bindLimit > 0 || *bindTunnelLimit > 0 {
log.Printf("limits: %d creates/hour/IP, %d binds/hour/IP, %d binds/hour/tunnel", *createLimit, *bindLimit, *bindTunnelLimit)
}
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")
}