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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user