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.
249 lines
6.8 KiB
Go
249 lines
6.8 KiB
Go
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)
|
|
}
|