262 lines
6.7 KiB
Go
262 lines
6.7 KiB
Go
// Package client implements the OpenTunnel client side: provisioning a
|
|
// tunnel, fetching and renewing its certificate, and carrying visitor
|
|
// connections over the bridge.
|
|
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Route is one local target for a tunnel route.
|
|
type Route struct {
|
|
Target string `json:"target"`
|
|
ProxyProtocol string `json:"proxy_protocol,omitempty"` // "", "v1", or "v2"
|
|
}
|
|
|
|
// Config is the single JSON file the CLI owns. It holds the API setting so
|
|
// commands no longer need OPENTUNNEL_API. The file is 0600 because it also
|
|
// holds the tunnel token.
|
|
type Config struct {
|
|
API string `json:"api"`
|
|
TunnelID string `json:"tunnel_id,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
CertificateExpiry string `json:"certificate_expiry,omitempty"`
|
|
AutoConnect bool `json:"auto_connect,omitempty"`
|
|
Routes map[string]Route `json:"routes,omitempty"`
|
|
}
|
|
|
|
// DefaultPath is ~/.config/tunnel/config.json (XDG_CONFIG_HOME is honored).
|
|
func DefaultPath() string {
|
|
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
|
return filepath.Join(dir, "tunnel", "config.json")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "config.json"
|
|
}
|
|
return filepath.Join(home, ".config", "tunnel", "config.json")
|
|
}
|
|
|
|
// Load reads the config, returning an empty one when the file does not exist.
|
|
func Load(path string) (*Config, error) {
|
|
cfg := &Config{Routes: map[string]Route{}}
|
|
data, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return cfg, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(data, cfg); err != nil {
|
|
return nil, fmt.Errorf("config %s: %w", path, err)
|
|
}
|
|
if cfg.Routes == nil {
|
|
cfg.Routes = map[string]Route{}
|
|
}
|
|
if err := cfg.Normalize(); err != nil {
|
|
return nil, fmt.Errorf("config %s: %w", path, err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// Save writes the config atomically as 0600.
|
|
func (c *Config) Save(path string) error {
|
|
if err := c.Normalize(); err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
tmp, err := os.CreateTemp(dir, ".config-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := tmp.Name()
|
|
defer os.Remove(name)
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(name, path)
|
|
}
|
|
|
|
// Normalize trims and validates the API and every route.
|
|
func (c *Config) Normalize() error {
|
|
if c.API != "" {
|
|
api, err := NormalizeAPI(c.API)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.API = api
|
|
}
|
|
if c.Routes == nil {
|
|
c.Routes = map[string]Route{}
|
|
}
|
|
for name, route := range c.Routes {
|
|
if !ValidRouteName(name) {
|
|
return fmt.Errorf("invalid route name %q", name)
|
|
}
|
|
target, err := NormalizeTarget(route.Target)
|
|
if err != nil {
|
|
return fmt.Errorf("route %q: %w", name, err)
|
|
}
|
|
route.Target = target
|
|
switch route.ProxyProtocol {
|
|
case "", "v1", "v2":
|
|
default:
|
|
return fmt.Errorf("route %q: invalid proxy_protocol %q", name, route.ProxyProtocol)
|
|
}
|
|
c.Routes[name] = route
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeAPI accepts URL, host, or host:port and returns a scheme-qualified
|
|
// URL without a trailing slash.
|
|
func NormalizeAPI(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", errors.New("api is required")
|
|
}
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return "", fmt.Errorf("invalid api URL %q", raw)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return "", fmt.Errorf("api scheme must be http or https, got %q", u.Scheme)
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/")
|
|
u.Fragment = ""
|
|
u.RawQuery = ""
|
|
return u.String(), nil
|
|
}
|
|
|
|
var routeNameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
|
|
// ValidRouteName reports whether name is "@" or one lowercase DNS label.
|
|
func ValidRouteName(name string) bool {
|
|
return name == "@" || routeNameRE.MatchString(name)
|
|
}
|
|
|
|
// NormalizeTarget turns a bare port into 127.0.0.1:port and validates the
|
|
// resulting host:port.
|
|
func NormalizeTarget(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", errors.New("target is required")
|
|
}
|
|
if _, err := strconv.Atoi(raw); err == nil {
|
|
raw = "127.0.0.1:" + raw
|
|
} else if strings.HasPrefix(raw, ":") {
|
|
raw = "127.0.0.1" + raw
|
|
}
|
|
host, port, err := net.SplitHostPort(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("target %q is not host:port or a port", raw)
|
|
}
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
|
return "", fmt.Errorf("target %q has an invalid port", raw)
|
|
}
|
|
return net.JoinHostPort(host, port), nil
|
|
}
|
|
|
|
// AddRoute validates and stores a route. proxy may be empty, "v1", or "v2".
|
|
func (c *Config) AddRoute(name, target, proxy string) error {
|
|
if !ValidRouteName(name) {
|
|
return fmt.Errorf("invalid route name %q (use @ or one lowercase DNS label)", name)
|
|
}
|
|
normalized, err := NormalizeTarget(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch proxy {
|
|
case "", "v1", "v2":
|
|
default:
|
|
return fmt.Errorf("invalid proxy protocol %q", proxy)
|
|
}
|
|
if c.Routes == nil {
|
|
c.Routes = map[string]Route{}
|
|
}
|
|
c.Routes[name] = Route{Target: normalized, ProxyProtocol: proxy}
|
|
return nil
|
|
}
|
|
|
|
// RemoveRoute deletes a route, reporting whether it existed.
|
|
func (c *Config) RemoveRoute(name string) bool {
|
|
if _, ok := c.Routes[name]; !ok {
|
|
return false
|
|
}
|
|
delete(c.Routes, name)
|
|
return true
|
|
}
|
|
|
|
// RouteURL is the public URL for a route on this tunnel.
|
|
func (c *Config) RouteURL(name string) string {
|
|
if c.Hostname == "" {
|
|
return ""
|
|
}
|
|
if name == "@" {
|
|
return "https://" + c.Hostname
|
|
}
|
|
return "https://" + name + "." + c.Hostname
|
|
}
|
|
|
|
// Paths are the files stored next to the config.
|
|
type Paths struct {
|
|
Config string
|
|
Dir string
|
|
Key string
|
|
CSR string
|
|
Cert string
|
|
Chain string
|
|
PID string
|
|
Log string
|
|
}
|
|
|
|
// ConfigPaths returns the standard file layout for a config path.
|
|
func ConfigPaths(configPath string) Paths {
|
|
dir := filepath.Dir(configPath)
|
|
return Paths{
|
|
Config: configPath,
|
|
Dir: dir,
|
|
Key: filepath.Join(dir, "private-key.pem"),
|
|
CSR: filepath.Join(dir, "csr.pem"),
|
|
Cert: filepath.Join(dir, "certificate.pem"),
|
|
Chain: filepath.Join(dir, "chain.pem"),
|
|
PID: filepath.Join(dir, "tunnel.pid"),
|
|
Log: filepath.Join(dir, "tunnel.log"),
|
|
}
|
|
}
|