Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
211 lines
7.1 KiB
Go
211 lines
7.1 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")
|
|
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")
|
|
}
|