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.
This commit is contained in:
2026-10-26 00:32:31 +08:00
parent cba7d2366b
commit ebae420ab1
27 changed files with 3745 additions and 56 deletions
+581
View File
@@ -0,0 +1,581 @@
package main
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"time"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/client"
)
var version = "dev"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
configPath := client.DefaultPath()
if env := os.Getenv("TUNNEL_CONFIG"); env != "" {
configPath = env
}
fs := flag.NewFlagSet("tunnel", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.StringVar(&configPath, "config", configPath, "config file (default ~/.config/tunnel/config.json)")
fs.Usage = func() { usage(fs.Output()) }
if err := fs.Parse(args); err != nil {
return 2
}
rest := fs.Args()
if len(rest) == 0 {
usage(os.Stderr)
return 2
}
command, rest := rest[0], rest[1:]
switch command {
case "config":
return runConfig(configPath, rest)
case "route":
return runRoute(configPath, rest)
case "import":
return runImport(configPath, rest)
case "up":
return runUp(configPath)
case "down":
return runDown(configPath)
case "serve":
return runServe(configPath)
case "status":
return runStatus(configPath)
case "logs":
return runLogs(configPath)
case "version":
fmt.Println("tunnel", version)
return 0
case "help", "-h", "--help":
usage(os.Stdout)
return 0
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", command)
usage(os.Stderr)
return 2
}
}
func usage(w io.Writer) {
fmt.Fprint(w, `tunnel - OpenTunnel client for a self-hosted server
Usage:
tunnel [--config FILE] <command> [options]
Commands:
config set-api <url> Store the server API URL in the config
config show Show the config (never the token)
route add <port|host:port> [--name NAME] [--proxy-protocol v1|v2]
route remove <name>
route list
import --profile NAME --api URL
Take over an existing official opentunnel profile
up Provision if needed and run in the background
down Stop the background service
serve Run in the foreground (debugging)
status Show the tunnel, certificate, routes and daemon
logs Print the end of the daemon log
Examples:
tunnel config set-api https://tunnel.tzgyn.dev
tunnel route add 4096 --name api
tunnel up
`)
}
func runConfig(configPath string, args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: tunnel config set-api <url> | tunnel config show")
return 2
}
switch args[0] {
case "set-api", "set":
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "usage: tunnel config set-api <url>")
return 2
}
api, err := client.NormalizeAPI(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
old := cfg.API
cfg.API = api
if err := cfg.Save(configPath); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("API set to %s\n", api)
if old != "" && old != api && cfg.TunnelID != "" {
fmt.Println("Note: the saved tunnel identity was kept; if that tunnel does not exist on this server, remove the config directory and start again.")
}
return 0
case "show":
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
printConfig(configPath, cfg)
return 0
default:
fmt.Fprintln(os.Stderr, "usage: tunnel config set-api <url> | tunnel config show")
return 2
}
}
func printConfig(configPath string, cfg *client.Config) {
fmt.Printf("Config: %s\n", configPath)
fmt.Printf("API: %s\n", valueOr(cfg.API, "(not set)"))
fmt.Printf("Tunnel: %s\n", valueOr(cfg.TunnelID, "(not created)"))
fmt.Printf("Hostname: %s\n", valueOr(cfg.Hostname, "(not created)"))
fmt.Printf("Cert: %s\n", valueOr(cfg.CertificateExpiry, "(not issued)"))
if len(cfg.Routes) == 0 {
fmt.Println("Routes: (none)")
return
}
fmt.Println("Routes:")
for _, name := range sortedRouteNames(cfg) {
route := cfg.Routes[name]
fmt.Printf(" %-20s -> %-24s %s\n", name, route.Target, cfg.RouteURL(name))
}
}
func runRoute(configPath string, args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: tunnel route add|remove|list")
return 2
}
switch args[0] {
case "add":
var target, name, proxy string
for i := 1; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--name":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "--name needs a value")
return 2
}
i++
name = args[i]
case strings.HasPrefix(arg, "--name="):
name = strings.TrimPrefix(arg, "--name=")
case arg == "--proxy-protocol":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "--proxy-protocol needs v1 or v2")
return 2
}
i++
proxy = args[i]
case strings.HasPrefix(arg, "--proxy-protocol="):
proxy = strings.TrimPrefix(arg, "--proxy-protocol=")
case strings.HasPrefix(arg, "-"):
fmt.Fprintf(os.Stderr, "unknown option %s\n", arg)
return 2
case target == "":
target = arg
default:
fmt.Fprintf(os.Stderr, "unexpected argument %q\n", arg)
return 2
}
}
if target == "" {
fmt.Fprintln(os.Stderr, "usage: tunnel route add <port|host:port> [--name NAME]")
return 2
}
normalized, err := client.NormalizeTarget(target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if name == "" {
name = randomRouteName()
}
if !client.ValidRouteName(name) {
fmt.Fprintf(os.Stderr, "invalid route name %q\n", name)
return 1
}
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if cfg.API == "" {
fmt.Fprintln(os.Stderr, "no API configured; run: tunnel config set-api https://your-server")
return 1
}
svc, err := client.NewService(configPath, version, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
err = svc.Ensure(ctx)
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, "provision tunnel: %v\n", err)
return 1
}
cfg = svc.Config
if err := cfg.AddRoute(name, normalized, proxy); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := cfg.Save(configPath); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println("Tunnel ready.")
fmt.Printf("Route %s -> %s\n", name, cfg.RouteURL(name))
if err := startDaemon(configPath); err != nil {
fmt.Fprintf(os.Stderr, "service: %v\n", err)
return 1
}
return 0
case "remove":
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "usage: tunnel route remove <name>")
return 2
}
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if !cfg.RemoveRoute(args[1]) {
fmt.Fprintf(os.Stderr, "no route named %q\n", args[1])
return 1
}
if err := cfg.Save(configPath); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("Removed route %s (the service reloads within a few seconds).\n", args[1])
return 0
case "list":
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if len(cfg.Routes) == 0 {
fmt.Println("No routes.")
return 0
}
for _, name := range sortedRouteNames(cfg) {
route := cfg.Routes[name]
fmt.Printf("%-20s -> %-24s %s\n", name, route.Target, cfg.RouteURL(name))
}
return 0
default:
fmt.Fprintln(os.Stderr, "usage: tunnel route add|remove|list")
return 2
}
}
func runImport(configPath string, args []string) int {
fs := flag.NewFlagSet("import", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var profile, api string
var force bool
fs.StringVar(&profile, "profile", "default", "official opentunnel profile name")
fs.StringVar(&api, "api", "", "server API URL")
fs.BoolVar(&force, "force", false, "overwrite an existing config")
if err := fs.Parse(args); err != nil {
return 2
}
result, err := client.Import(client.ImportOptions{
Profile: profile,
API: api,
ConfigPath: configPath,
Force: force,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
sort.Strings(result.Routes)
fmt.Printf("Imported %s (%s) from %s\n", result.TunnelID, result.Hostname, result.Source)
fmt.Printf("Config: %s\n", result.ConfigPath)
if len(result.Routes) == 0 {
fmt.Println("Routes: (none)")
} else {
fmt.Printf("Routes: %s\n", strings.Join(result.Routes, ", "))
}
fmt.Println()
fmt.Println("Stop the official service before starting this one:")
fmt.Printf(" opentunnel --profile %s down\n", profile)
fmt.Println("Then run:")
fmt.Printf(" tunnel --config %s up\n", result.ConfigPath)
return 0
}
func runUp(configPath string) int {
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if cfg.API == "" {
fmt.Fprintln(os.Stderr, "no API configured; run: tunnel config set-api https://your-server")
return 1
}
if err := startDaemon(configPath); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
func startDaemon(configPath string) error {
if pid, err := readPID(client.ConfigPaths(configPath).PID); err == nil && processAlive(pid) {
fmt.Printf("Already running (pid %d).\n", pid)
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
paths := client.ConfigPaths(configPath)
if err := os.MkdirAll(paths.Dir, 0o700); err != nil {
return err
}
logFile, err := os.OpenFile(paths.Log, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
cmd := exec.Command(exe, "--config", configPath, "serve")
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
logFile.Close()
return err
}
logFile.Close()
pid := cmd.Process.Pid
_ = cmd.Process.Release()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
pidOnDisk, err := readPID(paths.PID)
if err == nil && pidOnDisk == pid && processAlive(pid) {
fmt.Printf("Started (pid %d). Log: %s\n", pid, paths.Log)
return nil
}
if !processAlive(pid) {
break
}
time.Sleep(100 * time.Millisecond)
}
if processAlive(pid) {
fmt.Printf("Started (pid %d). Log: %s\n", pid, paths.Log)
return nil
}
tailLog(paths.Log, 20)
return errors.New("service exited during startup; see the log above")
}
func runDown(configPath string) int {
paths := client.ConfigPaths(configPath)
pid, err := readPID(paths.PID)
if err != nil {
fmt.Println("Not running.")
return 0
}
if !processAlive(pid) {
_ = os.Remove(paths.PID)
fmt.Println("Not running (removed stale pid file).")
return 0
}
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
fmt.Fprintf(os.Stderr, "stop pid %d: %v\n", pid, err)
return 1
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
_ = os.Remove(paths.PID)
fmt.Printf("Stopped (pid %d).\n", pid)
return 0
}
time.Sleep(100 * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "pid %d did not stop within 5s\n", pid)
return 1
}
func runServe(configPath string) int {
paths := client.ConfigPaths(configPath)
if pid, err := readPID(paths.PID); err == nil && pid != os.Getpid() && processAlive(pid) {
fmt.Fprintf(os.Stderr, "already running (pid %d)\n", pid)
return 1
}
svc, err := client.NewService(configPath, version, log.New(os.Stderr, "", log.LstdFlags))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := writePID(paths.PID); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer func() {
if pid, err := readPID(paths.PID); err == nil && pid == os.Getpid() {
_ = os.Remove(paths.PID)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
svc.Log.Printf("service stopped: %v", err)
return 1
}
return 0
}
func runStatus(configPath string) int {
cfg, err := client.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
printConfig(configPath, cfg)
paths := client.ConfigPaths(configPath)
if pid, err := readPID(paths.PID); err == nil && processAlive(pid) {
fmt.Printf("Service: running (pid %d)\n", pid)
} else {
fmt.Println("Service: stopped")
}
if cfg.API == "" || cfg.TunnelID == "" || cfg.Token == "" {
return 0
}
svc, err := client.NewService(configPath, version, nil)
if err != nil {
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
info, err := svc.API.GetTunnel(ctx, cfg.TunnelID, cfg.Token)
if err != nil {
fmt.Printf("Server: error: %v\n", err)
return 0
}
fmt.Printf("Server: %s\n", info.State)
cert, err := svc.API.GetCertificate(ctx, cfg.TunnelID, cfg.Token)
if err == nil {
line := cert.State.Type
if cert.State.Type == "ready" {
line += " (" + cert.State.Expiry + ")"
}
if cert.State.Reason != "" {
line += ": " + cert.State.Reason
}
fmt.Printf("Cert state: %s\n", line)
}
return 0
}
func runLogs(configPath string) int {
paths := client.ConfigPaths(configPath)
if _, err := os.Stat(paths.Log); err != nil {
fmt.Println("No log yet:", paths.Log)
return 0
}
fmt.Println("Log:", paths.Log)
tailLog(paths.Log, 100)
return 0
}
func sortedRouteNames(cfg *client.Config) []string {
names := make([]string, 0, len(cfg.Routes))
for name := range cfg.Routes {
names = append(names, name)
}
sort.Strings(names)
return names
}
func writePID(path string) error {
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600)
}
func readPID(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, errors.New("invalid pid file")
}
return pid, nil
}
func processAlive(pid int) bool {
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
func tailLog(path string, lines int) {
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, 1<<20))
if err != nil {
return
}
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
for _, line := range all {
if line != "" {
fmt.Println(line)
}
}
}
func randomRouteName() string {
buf := make([]byte, 10)
if _, err := rand.Read(buf); err != nil {
return "route"
}
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
}
func valueOr(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
+20
View File
@@ -69,6 +69,9 @@ func main() {
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 == "" {
@@ -117,6 +120,16 @@ func main() {
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,
@@ -144,12 +157,19 @@ func main() {
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()