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
+248
View File
@@ -0,0 +1,248 @@
package client
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// ImportOptions describes importing an official opentunnel CLI profile.
type ImportOptions struct {
Profile string
API string
ConfigPath string
Force bool
}
// ImportResult summarizes a successful import.
type ImportResult struct {
TunnelID string
Hostname string
Routes []string
ConfigPath string
Source string
}
type officialTunnel struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
CertificateExpiry string `json:"certificateExpiry"`
}
// Import copies the identity and routes of the official Rust CLI into this
// client's JSON config. The tunnel ID, token, private key and certificate are
// preserved, so the server sees the same tunnel.
func Import(opts ImportOptions) (ImportResult, error) {
if opts.Profile == "" {
opts.Profile = "default"
}
if !routeNameRE.MatchString(opts.Profile) {
return ImportResult{}, fmt.Errorf("invalid profile name %q", opts.Profile)
}
if opts.API == "" {
return ImportResult{}, errors.New("--api is required")
}
api, err := NormalizeAPI(opts.API)
if err != nil {
return ImportResult{}, err
}
if opts.ConfigPath == "" {
opts.ConfigPath = DefaultPath()
}
if _, err := os.Stat(opts.ConfigPath); err == nil && !opts.Force {
return ImportResult{}, fmt.Errorf("%s already exists; use --force to overwrite", opts.ConfigPath)
}
dataDir := xdgDir("XDG_DATA_HOME", ".local/share", "opentunnel", opts.Profile)
configDir := xdgDir("XDG_CONFIG_HOME", ".config", "opentunnel")
routesPath := filepath.Join(configDir, opts.Profile+".toml")
rawTunnel, err := os.ReadFile(filepath.Join(dataDir, "tunnel.json"))
if err != nil {
return ImportResult{}, fmt.Errorf("official tunnel.json: %w", err)
}
var official officialTunnel
if err := json.Unmarshal(rawTunnel, &official); err != nil {
return ImportResult{}, fmt.Errorf("official tunnel.json: %w", err)
}
if official.ID == "" || official.Hostname == "" {
return ImportResult{}, errors.New("official tunnel.json has no id/hostname")
}
token, err := os.ReadFile(filepath.Join(dataDir, "token"))
if err != nil {
return ImportResult{}, fmt.Errorf("official token: %w", err)
}
keyPEM, err := os.ReadFile(filepath.Join(dataDir, "private-key.pem"))
if err != nil {
return ImportResult{}, fmt.Errorf("official private key: %w", err)
}
if _, err := parseKey(keyPEM); err != nil {
return ImportResult{}, fmt.Errorf("official private key: %w", err)
}
certPEM, err := os.ReadFile(filepath.Join(dataDir, "certificate.pem"))
if err != nil {
return ImportResult{}, fmt.Errorf("official certificate: %w", err)
}
chainPEM, err := os.ReadFile(filepath.Join(dataDir, "chain.pem"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return ImportResult{}, fmt.Errorf("official chain: %w", err)
}
paths := ConfigPaths(opts.ConfigPath)
if err := os.MkdirAll(paths.Dir, 0o700); err != nil {
return ImportResult{}, err
}
if err := writeAtomic(paths.Key, keyPEM, 0o600); err != nil {
return ImportResult{}, err
}
if err := writeAtomic(paths.Cert, certPEM, 0o644); err != nil {
return ImportResult{}, err
}
if err := writeAtomic(paths.Chain, chainPEM, 0o644); err != nil {
return ImportResult{}, err
}
if _, _, err := EnsureIdentity(paths, official.Hostname); err != nil {
return ImportResult{}, fmt.Errorf("local CSR: %w", err)
}
cfg := &Config{
API: api,
TunnelID: official.ID,
Hostname: official.Hostname,
Token: strings.TrimSpace(string(token)),
CertificateExpiry: official.CertificateExpiry,
Routes: map[string]Route{},
}
routes, err := readOfficialRoutes(routesPath)
if err != nil {
return ImportResult{}, err
}
for name, route := range routes {
if err := cfg.AddRoute(name, route.Target, route.ProxyProtocol); err != nil {
return ImportResult{}, err
}
}
if cfg.Token == "" {
return ImportResult{}, errors.New("official token file is empty")
}
if err := cfg.Save(opts.ConfigPath); err != nil {
return ImportResult{}, err
}
names := make([]string, 0, len(cfg.Routes))
for name := range cfg.Routes {
names = append(names, name)
}
return ImportResult{
TunnelID: cfg.TunnelID,
Hostname: cfg.Hostname,
Routes: names,
ConfigPath: opts.ConfigPath,
Source: dataDir,
}, nil
}
func xdgDir(env, fallback string, parts ...string) string {
base := os.Getenv(env)
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
base = "."
} else {
base = filepath.Join(home, fallback)
}
}
return filepath.Join(append([]string{base}, parts...)...)
}
// readOfficialRoutes parses the subset of TOML the official CLI writes:
//
// [routes]
// web = "127.0.0.1:3000"
// api = { target = "127.0.0.1:4000", proxy_protocol = "v2" }
func readOfficialRoutes(path string) (map[string]Route, error) {
out := map[string]Route{}
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return out, nil
}
if err != nil {
return nil, err
}
defer file.Close()
inRoutes := false
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inRoutes = line == "[routes]"
continue
}
if !inRoutes {
continue
}
name, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("%s: bad route line %q", path, line)
}
name = strings.TrimSpace(name)
if unquoted, err := strconv.Unquote(name); err == nil {
name = unquoted
}
value = strings.TrimSpace(value)
route, err := parseRouteValue(value)
if err != nil {
return nil, fmt.Errorf("%s: route %s: %w", path, name, err)
}
out[name] = route
}
if err := scanner.Err(); err != nil {
return nil, err
}
return out, nil
}
func parseRouteValue(value string) (Route, error) {
if strings.HasPrefix(value, "\"") {
target, err := strconv.Unquote(value)
if err != nil {
return Route{}, err
}
return Route{Target: target}, nil
}
if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") {
route := Route{}
body := strings.TrimSpace(value[1 : len(value)-1])
for _, field := range strings.Split(body, ",") {
key, val, ok := strings.Cut(strings.TrimSpace(field), "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
unquoted, err := strconv.Unquote(val)
if err != nil {
return Route{}, err
}
switch key {
case "target":
route.Target = unquoted
case "proxy_protocol":
route.ProxyProtocol = unquoted
}
}
if route.Target == "" {
return Route{}, errors.New("inline route has no target")
}
return route, nil
}
return Route{}, fmt.Errorf("unsupported route value %q", value)
}