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
+271
View File
@@ -0,0 +1,271 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// TunnelInfo is the API's tunnel record.
type TunnelInfo struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
State string `json:"state"`
CertificateID string `json:"certificateID,omitempty"`
}
// CertState is the API's certificate state union.
type CertState struct {
Type string `json:"type"`
Token string `json:"token,omitempty"`
Key string `json:"key,omitempty"`
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
Expiry string `json:"expiry,omitempty"`
Reason string `json:"reason,omitempty"`
}
// CertificateInfo is the API's certificate envelope.
type CertificateInfo struct {
ID string `json:"id"`
State CertState `json:"state"`
}
// APIError is a non-2xx API answer, including the contract's retry hints.
type APIError struct {
Status int
Tag string
Message string
RetryAfter time.Duration
}
func (e *APIError) Error() string {
if e.Tag != "" {
return fmt.Sprintf("%s: %s", e.Tag, e.Message)
}
return fmt.Sprintf("api returned HTTP %d: %s", e.Status, e.Message)
}
// APIClient talks to the tunnel server's HTTP API.
type APIClient struct {
Base string
HTTP *http.Client
}
func NewAPIClient(base string) *APIClient {
return &APIClient{
Base: strings.TrimRight(base, "/"),
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
type repeat int
const (
repeatIdempotent repeat = iota // GET, same-CSR bind: retry network errors and 5xx
repeatOnce // create/delete: only when the request cannot have arrived
)
func (c *APIClient) do(ctx context.Context, method, path string, body any, token string, repeat repeat) ([]byte, error) {
var payload []byte
if body != nil {
var err error
payload, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
if attempt < 6 && repeat == repeatIdempotent && ctx.Err() == nil {
if !sleep(ctx, backoff(attempt, 0)) {
return nil, ctx.Err()
}
continue
}
return nil, err
}
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return data, nil
}
apiErr := parseAPIError(resp, data)
if attempt < 6 && transient(resp.StatusCode, repeat) && ctx.Err() == nil {
if !sleep(ctx, backoff(attempt, apiErr.RetryAfter)) {
return nil, ctx.Err()
}
continue
}
return nil, apiErr
}
}
func parseAPIError(resp *http.Response, data []byte) *APIError {
e := &APIError{Status: resp.StatusCode}
var body struct {
Tag string `json:"_tag"`
Message string `json:"message"`
}
if json.Unmarshal(data, &body) == nil {
e.Tag, e.Message = body.Tag, body.Message
}
if e.Message == "" {
e.Message = strings.TrimSpace(string(data))
if e.Message == "" {
e.Message = http.StatusText(resp.StatusCode)
}
}
if after := resp.Header.Get("Retry-After"); after != "" {
if seconds, err := strconv.Atoi(after); err == nil {
e.RetryAfter = time.Duration(seconds) * time.Second
}
}
return e
}
func transient(status int, repeat repeat) bool {
if status == 429 {
return true
}
if repeat == repeatIdempotent {
return status >= 500 && status <= 599
}
switch status {
case 503, 521, 522, 523, 525:
return true
}
return false
}
func backoff(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
if retryAfter > 8*time.Second {
retryAfter = 8 * time.Second
}
return retryAfter
}
wait := 250 * time.Millisecond << attempt
if wait > 8*time.Second {
wait = 8 * time.Second
}
// Jitter between half and all of the delay.
half := int64(wait / 2)
return time.Duration(half + rand.Int63n(half+1))
}
func sleep(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// CreateTunnel creates a new tunnel and returns its one-time token.
func (c *APIClient) CreateTunnel(ctx context.Context) (TunnelInfo, string, error) {
data, err := c.do(ctx, http.MethodPost, "/api/tunnel", struct{}{}, "", repeatOnce)
if err != nil {
return TunnelInfo{}, "", err
}
var out struct {
Tunnel TunnelInfo `json:"tunnel"`
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil {
return TunnelInfo{}, "", fmt.Errorf("create tunnel: %w", err)
}
if out.Tunnel.ID == "" || out.Token == "" {
return TunnelInfo{}, "", fmt.Errorf("create tunnel: incomplete response")
}
return out.Tunnel, out.Token, nil
}
// GetTunnel reads a tunnel.
func (c *APIClient) GetTunnel(ctx context.Context, id, token string) (TunnelInfo, error) {
data, err := c.do(ctx, http.MethodGet, "/api/tunnel/"+url.PathEscape(id), nil, token, repeatIdempotent)
if err != nil {
return TunnelInfo{}, err
}
var out TunnelInfo
if err := json.Unmarshal(data, &out); err != nil {
return TunnelInfo{}, fmt.Errorf("get tunnel: %w", err)
}
return out, nil
}
// BindCertificate submits the tunnel's CSR. Re-binding the same CSR is
// idempotent and is retried.
func (c *APIClient) BindCertificate(ctx context.Context, id, token, csr string) (CertificateInfo, error) {
data, err := c.do(ctx, http.MethodPost, "/api/tunnel/"+url.PathEscape(id)+"/certificate",
map[string]string{"csr": csr}, token, repeatIdempotent)
if err != nil {
return CertificateInfo{}, err
}
return decodeCertificate(data)
}
// GetCertificate reads the current certificate state.
func (c *APIClient) GetCertificate(ctx context.Context, id, token string) (CertificateInfo, error) {
data, err := c.do(ctx, http.MethodGet, "/api/tunnel/"+url.PathEscape(id)+"/certificate", nil, token, repeatIdempotent)
if err != nil {
return CertificateInfo{}, err
}
return decodeCertificate(data)
}
func decodeCertificate(data []byte) (CertificateInfo, error) {
var out CertificateInfo
if err := json.Unmarshal(data, &out); err != nil {
return CertificateInfo{}, fmt.Errorf("certificate response: %w", err)
}
return out, nil
}
// DeleteTunnel deletes a tunnel.
func (c *APIClient) DeleteTunnel(ctx context.Context, id, token string) error {
_, err := c.do(ctx, http.MethodDelete, "/api/tunnel/"+url.PathEscape(id), nil, token, repeatOnce)
return err
}
// WebSocketURL returns the bridge URL for a tunnel.
func (c *APIClient) WebSocketURL(id string) (string, error) {
u, err := url.Parse(c.Base)
if err != nil {
return "", err
}
if u.Scheme == "https" {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/api/tunnel/" + url.PathEscape(id) + "/connect"
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}
+648
View File
@@ -0,0 +1,648 @@
package client
import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
const (
bridgeSubprotocol = "opentunnel"
maxPayload = 32768
maxConnsPerBridge = 256
)
var (
errFatalAttach = errors.New("fatal attach error")
errBackpressure = errors.New("bridge data queue full")
)
type attachOut struct {
Type string `json:"type"`
Token string `json:"token"`
Transport string `json:"transport"`
Routes []string `json:"routes"`
Client struct {
Version string `json:"version"`
MaxConns int `json:"max_conns"`
} `json:"client"`
}
type serverControl struct {
Type string `json:"type"`
Session string `json:"session,omitempty"`
Routes []string `json:"routes,omitempty"`
HeartbeatMs int `json:"heartbeat_ms,omitempty"`
IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"`
Conn *uint32 `json:"conn,omitempty"`
Peer string `json:"peer,omitempty"`
SNI string `json:"sni,omitempty"`
ALPN string `json:"alpn,omitempty"`
Code string `json:"code,omitempty"`
TimeSent int64 `json:"time_sent,omitempty"`
Reason string `json:"reason,omitempty"`
}
type outbound struct {
typ websocket.MessageType
data []byte
}
type session struct {
svc *Service
cfg *Config
log *log.Logger
ws *websocket.Conn
routes map[string]Route
heartbeat time.Duration
idle time.Duration
control chan outbound
data chan outbound
done chan struct{}
closeOnce sync.Once
mu sync.Mutex
conns map[uint32]*bridgeConn
lastRecv atomic.Int64
}
func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
wsURL, err := s.API.WebSocketURL(cfg.TunnelID)
if err != nil {
return nil, err
}
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
ws, _, err := websocket.Dial(dialCtx, wsURL, &websocket.DialOptions{
HTTPHeader: http.Header{"Authorization": []string{"Bearer " + cfg.Token}},
Subprotocols: []string{bridgeSubprotocol},
CompressionMode: websocket.CompressionDisabled,
})
cancel()
if err != nil {
return nil, fmt.Errorf("dial bridge: %w", err)
}
ws.SetReadLimit(64 * 1024)
routes := make([]string, 0, len(cfg.Routes))
targets := make(map[string]Route, len(cfg.Routes))
for name, route := range cfg.Routes {
routes = append(routes, name)
targets[name] = route
}
sort.Strings(routes)
if len(routes) > 128 {
ws.Close(websocket.StatusPolicyViolation, "too many routes")
return nil, fmt.Errorf("too many routes (%d, max 128)", len(routes))
}
var attach attachOut
attach.Type = "attach"
attach.Token = cfg.Token
attach.Transport = "ws"
attach.Routes = routes
attach.Client.Version = s.Version
attach.Client.MaxConns = maxConnsPerBridge
if err := writeJSON(ctx, ws, attach); err != nil {
ws.Close(websocket.StatusInternalError, "attach write")
return nil, err
}
readCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
typ, raw, err := ws.Read(readCtx)
cancel()
if err != nil {
ws.Close(websocket.StatusInternalError, "attach read")
return nil, fmt.Errorf("attach: %w", err)
}
if typ != websocket.MessageText {
ws.Close(websocket.StatusProtocolError, "expected attach answer")
return nil, errors.New("attach: unexpected binary answer")
}
var answer serverControl
if err := json.Unmarshal(raw, &answer); err != nil {
ws.Close(websocket.StatusProtocolError, "bad attach answer")
return nil, fmt.Errorf("attach answer: %w", err)
}
switch answer.Type {
case "attached":
case "attach_error":
ws.Close(websocket.StatusPolicyViolation, answer.Code)
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
return nil, fmt.Errorf("%w: %s", errFatalAttach, answer.Code)
}
return nil, fmt.Errorf("attach: %s", answer.Code)
default:
ws.Close(websocket.StatusProtocolError, "expected attached")
return nil, fmt.Errorf("attach: unexpected message %q", answer.Type)
}
ws.SetReadLimit(4 + maxPayload)
heartbeat := time.Duration(answer.HeartbeatMs) * time.Millisecond
if heartbeat <= 0 {
heartbeat = 15 * time.Second
}
idle := time.Duration(answer.IdleTimeoutMs) * time.Millisecond
if idle <= 0 {
idle = 45 * time.Second
}
sess := &session{
svc: s,
cfg: cfg,
log: s.Log,
ws: ws,
routes: targets,
heartbeat: heartbeat,
idle: idle,
control: make(chan outbound, 64),
data: make(chan outbound, 256),
done: make(chan struct{}),
conns: map[uint32]*bridgeConn{},
}
sess.touch()
go sess.writeLoop()
go sess.heartbeatLoop()
go sess.readLoop()
s.Log.Printf("attached: session %s, %d route(s), heartbeat %s", answer.Session, len(routes), heartbeat)
return sess, nil
}
func writeJSON(ctx context.Context, ws *websocket.Conn, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return ws.Write(writeCtx, websocket.MessageText, data)
}
func (s *session) touch() { s.lastRecv.Store(time.Now().UnixMilli()) }
func (s *session) readLoop() {
ctx := context.Background()
for {
typ, raw, err := s.ws.Read(ctx)
if err != nil {
s.close("read: " + err.Error())
return
}
s.touch()
if typ == websocket.MessageText {
s.handleControl(raw)
continue
}
if len(raw) < 4 {
continue
}
id := binary.BigEndian.Uint32(raw[:4])
s.mu.Lock()
bc := s.conns[id]
s.mu.Unlock()
if bc == nil {
continue
}
payload := raw[4:]
select {
case bc.incoming <- payload:
case <-bc.done:
default:
// The local side is not draining; drop this connection rather
// than blocking the bridge read loop.
s.resetConn(id, "upstream_io_error")
}
}
}
func (s *session) handleControl(raw []byte) {
var msg serverControl
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
switch msg.Type {
case "ping":
s.sendControl(serverControl{Type: "pong", TimeSent: msg.TimeSent})
case "pong":
// Any received message already refreshed lastRecv.
case "open":
if msg.Conn != nil {
s.handleOpen(*msg.Conn, msg.Peer, msg.SNI, msg.ALPN)
}
case "end":
if msg.Conn != nil {
s.endFromServer(*msg.Conn)
}
case "reset":
if msg.Conn != nil {
s.resetFromServer(*msg.Conn)
}
case "drain":
s.log.Printf("server drained the bridge: %s", msg.Reason)
s.close("server drain")
case "attached", "attach_error":
// Handled during connect.
}
}
func (s *session) handleOpen(id uint32, peer, sni, alpn string) {
route := routeForSNI(s.cfg.Hostname, sni)
if route == "" {
s.sendReset(id, "unknown_route")
return
}
target, ok := s.routes[route]
if !ok {
s.sendReset(id, "unknown_route")
return
}
s.mu.Lock()
if len(s.conns) >= maxConnsPerBridge {
s.mu.Unlock()
s.sendReset(id, "too_many_connections")
return
}
bc := newBridgeConn(id, s)
bc.peer, bc.sni, bc.alpn = peer, sni, alpn
s.conns[id] = bc
s.mu.Unlock()
go s.serveConn(bc, target, peer, sni, alpn)
}
func (s *session) serveConn(bc *bridgeConn, route Route, peer, sni, alpn string) {
defer func() {
s.mu.Lock()
if s.conns[bc.id] == bc {
delete(s.conns, bc.id)
}
s.mu.Unlock()
bc.fullClose()
}()
tlsConn := tls.Server(bc, s.svc.TLSConfig())
handshakeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
err := tlsConn.HandshakeContext(handshakeCtx)
cancel()
if err != nil {
s.log.Printf("tls handshake for %s: %v", sni, err)
s.sendReset(bc.id, "upstream_io_error")
return
}
target, err := net.DialTimeout("tcp", route.Target, 10*time.Second)
if err != nil {
s.log.Printf("connect %s: %v", route.Target, err)
s.sendReset(bc.id, "upstream_connect_failed")
return
}
defer target.Close()
if route.ProxyProtocol != "" {
if err := writeProxyHeader(target, route.ProxyProtocol, peer, sni); err != nil {
s.log.Printf("proxy protocol for %s: %v", sni, err)
s.sendReset(bc.id, "upstream_io_error")
return
}
}
// visitor -> target. On TLS EOF, half-close the target.
visitorDone := make(chan error, 1)
go func() {
_, err := io.Copy(target, tlsConn)
if tcp, ok := target.(*net.TCPConn); ok {
tcp.CloseWrite()
}
visitorDone <- err
}()
// target -> visitor. When the target ends, send TLS close_notify and an
// `end` control so the server half-closes the visitor.
targetDone := make(chan error, 1)
go func() {
_, err := io.Copy(tlsConn, target)
_ = tlsConn.CloseWrite()
s.sendEnd(bc.id)
targetDone <- err
}()
var firstErr error
for done := 0; done < 2; {
select {
case err := <-visitorDone:
done++
if err != nil && !isClosedError(err) {
firstErr = err
}
visitorDone = nil
case err := <-targetDone:
done++
if err != nil && !isClosedError(err) {
firstErr = err
}
targetDone = nil
case <-bc.done:
s.sendReset(bc.id, "io_error")
return
case <-time.After(60 * time.Second):
s.sendReset(bc.id, "idle_timeout")
return
}
}
if firstErr != nil {
s.sendReset(bc.id, "upstream_io_error")
}
}
func (s *session) endFromServer(id uint32) {
s.mu.Lock()
bc := s.conns[id]
s.mu.Unlock()
if bc != nil {
bc.remoteEnd()
}
}
func (s *session) resetFromServer(id uint32) {
s.mu.Lock()
bc := s.conns[id]
delete(s.conns, id)
s.mu.Unlock()
if bc != nil {
bc.remoteReset()
}
}
func (s *session) resetConn(id uint32, code string) {
s.mu.Lock()
bc := s.conns[id]
delete(s.conns, id)
s.mu.Unlock()
s.sendReset(id, code)
if bc != nil {
bc.remoteReset()
}
}
func (s *session) sendReset(id uint32, code string) {
s.sendControl(serverControl{Type: "reset", Conn: &id, Code: code})
}
func (s *session) sendControl(msg serverControl) {
data, err := json.Marshal(msg)
if err != nil {
return
}
select {
case s.control <- outbound{typ: websocket.MessageText, data: data}:
case <-s.done:
default:
s.close("control queue full")
}
}
func (s *session) sendData(frame []byte) error {
select {
case <-s.done:
return io.ErrClosedPipe
default:
}
select {
case s.data <- outbound{typ: websocket.MessageBinary, data: frame}:
return nil
case <-s.done:
return io.ErrClosedPipe
default:
return errBackpressure
}
}
// sendEnd queues a half-close after any already-queued data frames. It must
// not use the priority control queue: an `end` overtaking the final response
// bytes truncates the visitor's connection.
func (s *session) sendEnd(id uint32) {
data, err := json.Marshal(serverControl{Type: "end", Conn: &id})
if err != nil {
return
}
select {
case s.data <- outbound{typ: websocket.MessageText, data: data}:
case <-s.done:
default:
s.close("data queue full")
}
}
func (s *session) writeLoop() {
for {
// Give control messages priority over queued visitor data.
select {
case <-s.done:
return
default:
}
select {
case <-s.done:
return
case msg := <-s.control:
if err := s.write(msg); err != nil {
s.close("write: " + err.Error())
return
}
continue
default:
}
select {
case <-s.done:
return
case msg := <-s.control:
if err := s.write(msg); err != nil {
s.close("write: " + err.Error())
return
}
case frame := <-s.data:
if err := s.write(frame); err != nil {
s.close("write: " + err.Error())
return
}
}
}
}
func (s *session) write(msg outbound) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return s.ws.Write(ctx, msg.typ, msg.data)
}
func (s *session) heartbeatLoop() {
ticker := time.NewTicker(s.heartbeat)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case now := <-ticker.C:
if now.UnixMilli()-s.lastRecv.Load() > s.idle.Milliseconds() {
s.close("idle timeout")
return
}
s.sendControl(serverControl{Type: "ping", TimeSent: now.UnixMilli()})
}
}
}
func (s *session) close(reason string) {
s.closeOnce.Do(func() {
close(s.done)
s.ws.Close(websocket.StatusGoingAway, reason)
s.mu.Lock()
conns := make([]*bridgeConn, 0, len(s.conns))
for _, bc := range s.conns {
conns = append(conns, bc)
}
s.conns = map[uint32]*bridgeConn{}
s.mu.Unlock()
for _, bc := range conns {
bc.remoteReset()
}
})
}
// bridgeConn is a net.Conn fed by bridge frames.
type bridgeConn struct {
id uint32
session *session
incoming chan []byte
buf []byte
peer string
sni string
alpn string
mu sync.Mutex
readErr error
readEnd chan struct{}
done chan struct{}
once sync.Once
}
func newBridgeConn(id uint32, s *session) *bridgeConn {
return &bridgeConn{
id: id,
session: s,
incoming: make(chan []byte, 64),
readEnd: make(chan struct{}),
done: make(chan struct{}),
}
}
func (c *bridgeConn) Read(p []byte) (int, error) {
for len(c.buf) == 0 {
select {
case buf := <-c.incoming:
c.buf = buf
case <-c.readEnd:
c.mu.Lock()
err := c.readErr
c.mu.Unlock()
if err != nil {
return 0, err
}
return 0, io.EOF
}
}
n := copy(p, c.buf)
c.buf = c.buf[n:]
return n, nil
}
func (c *bridgeConn) Write(p []byte) (int, error) {
select {
case <-c.done:
return 0, io.ErrClosedPipe
default:
}
frame := make([]byte, 4+len(p))
binary.BigEndian.PutUint32(frame, c.id)
copy(frame[4:], p)
if err := c.session.sendData(frame); err != nil {
c.session.sendReset(c.id, "upstream_io_error")
return 0, err
}
return len(p), nil
}
func (c *bridgeConn) Close() error {
c.remoteReset()
return nil
}
func (c *bridgeConn) LocalAddr() net.Addr { return bridgeAddr("bridge") }
func (c *bridgeConn) RemoteAddr() net.Addr { return bridgeAddr(c.peer) }
func (c *bridgeConn) SetDeadline(time.Time) error { return nil }
func (c *bridgeConn) SetReadDeadline(time.Time) error { return nil }
func (c *bridgeConn) SetWriteDeadline(time.Time) error { return nil }
func (c *bridgeConn) remoteEnd() {
c.mu.Lock()
select {
case <-c.readEnd:
default:
close(c.readEnd)
}
c.mu.Unlock()
}
func (c *bridgeConn) remoteReset() {
c.mu.Lock()
if c.readErr == nil {
c.readErr = io.ErrClosedPipe
}
select {
case <-c.readEnd:
default:
close(c.readEnd)
}
c.mu.Unlock()
c.once.Do(func() { close(c.done) })
}
func (c *bridgeConn) fullClose() {
c.remoteReset()
}
type bridgeAddr string
func (a bridgeAddr) Network() string { return "bridge" }
func (a bridgeAddr) String() string { return string(a) }
func routeForSNI(hostname, sni string) string {
sni = strings.ToLower(strings.TrimSuffix(sni, "."))
hostname = strings.ToLower(hostname)
if sni == hostname {
return "@"
}
suffix := "." + hostname
if !strings.HasSuffix(sni, suffix) {
return ""
}
prefix := strings.TrimSuffix(sni, suffix)
if strings.Contains(prefix, ".") || !ValidRouteName(prefix) {
return ""
}
return prefix
}
func isClosedError(err error) bool {
return err == nil || errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed)
}
+211
View File
@@ -0,0 +1,211 @@
package client
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeAPI(t *testing.T) {
cases := map[string]string{
"tunnel.tzgyn.dev": "https://tunnel.tzgyn.dev",
"https://tunnel.tzgyn.dev/": "https://tunnel.tzgyn.dev",
"http://127.0.0.1:8080": "http://127.0.0.1:8080",
}
for input, want := range cases {
got, err := NormalizeAPI(input)
if err != nil || got != want {
t.Errorf("NormalizeAPI(%q) = %q, %v; want %q", input, got, err, want)
}
}
if _, err := NormalizeAPI("ftp://example.com"); err == nil {
t.Error("ftp should be rejected")
}
}
func TestNormalizeTarget(t *testing.T) {
cases := map[string]string{
"3000": "127.0.0.1:3000",
":3000": "127.0.0.1:3000",
"127.0.0.1:4096": "127.0.0.1:4096",
"localhost:8080": "localhost:8080",
"[::1]:3000": "[::1]:3000",
}
for input, want := range cases {
got, err := NormalizeTarget(input)
if err != nil || got != want {
t.Errorf("NormalizeTarget(%q) = %q, %v; want %q", input, got, err, want)
}
}
for _, bad := range []string{"", "http://x", "localhost", "127.0.0.1:0", "127.0.0.1:99999"} {
if _, err := NormalizeTarget(bad); err == nil {
t.Errorf("NormalizeTarget(%q) should fail", bad)
}
}
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
cfg := &Config{API: "https://tunnel.example", Routes: map[string]Route{}}
if err := cfg.AddRoute("api", "4096", ""); err != nil {
t.Fatal(err)
}
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("mode = %o, want 600", info.Mode().Perm())
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
if loaded.API != cfg.API || loaded.Routes["api"].Target != "127.0.0.1:4096" {
t.Errorf("round trip: %+v", loaded)
}
}
func TestEnsureIdentityReusesCSR(t *testing.T) {
paths := ConfigPaths(filepath.Join(t.TempDir(), "config.json"))
key1, csr1, err := EnsureIdentity(paths, "abc.tunnel.example")
if err != nil {
t.Fatal(err)
}
key2, csr2, err := EnsureIdentity(paths, "abc.tunnel.example")
if err != nil {
t.Fatal(err)
}
if string(key1) != string(key2) || string(csr1) != string(csr2) {
t.Error("identity was regenerated on the second call")
}
if !csrMatches(csr1, "abc.tunnel.example") {
t.Error("CSR does not match the hostname")
}
}
func TestRouteForSNI(t *testing.T) {
host := "abc.tunnel.example"
cases := map[string]string{
host: "@",
"api." + host: "api",
"API." + host: "api",
"api." + host + ".": "api",
"a.b." + host: "",
"other.example": "",
"api.abc.tunnel.example.x": "",
}
for sni, want := range cases {
if got := routeForSNI(host, sni); got != want {
t.Errorf("routeForSNI(%q) = %q, want %q", sni, got, want)
}
}
}
func TestReadOfficialRoutes(t *testing.T) {
path := filepath.Join(t.TempDir(), "coolify.toml")
body := `[routes]
api = "127.0.0.1:4096"
web = { target = "localhost:3000", proxy_protocol = "v2" }
`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
routes, err := readOfficialRoutes(path)
if err != nil {
t.Fatal(err)
}
if routes["api"].Target != "127.0.0.1:4096" {
t.Errorf("api = %+v", routes["api"])
}
if routes["web"].Target != "localhost:3000" || routes["web"].ProxyProtocol != "v2" {
t.Errorf("web = %+v", routes["web"])
}
}
func TestImportOfficialProfile(t *testing.T) {
root := t.TempDir()
dataHome := filepath.Join(root, "data")
configHome := filepath.Join(root, "config")
t.Setenv("XDG_DATA_HOME", dataHome)
t.Setenv("XDG_CONFIG_HOME", configHome)
sourceDir := filepath.Join(dataHome, "opentunnel", "coolify")
if err := os.MkdirAll(sourceDir, 0o700); err != nil {
t.Fatal(err)
}
officialPaths := ConfigPaths(filepath.Join(root, "official", "config.json"))
keyPEM, _, err := EnsureIdentity(officialPaths, "abc.tunnel.example")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "tunnel.json"), []byte(`{"id":"abc","hostname":"abc.tunnel.example","certificateExpiry":"2027-01-01T00:00:00Z"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "token"), []byte("tok\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "private-key.pem"), keyPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "certificate.pem"), []byte("dummy"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "chain.pem"), []byte("dummy"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(configHome, "opentunnel"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configHome, "opentunnel", "coolify.toml"), []byte("[routes]\napi = \"127.0.0.1:4096\"\n"), 0o600); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "client", "config.json")
result, err := Import(ImportOptions{Profile: "coolify", API: "https://tunnel.example", ConfigPath: target})
if err != nil {
t.Fatal(err)
}
if result.TunnelID != "abc" || result.Hostname != "abc.tunnel.example" {
t.Errorf("result = %+v", result)
}
cfg, err := Load(target)
if err != nil {
t.Fatal(err)
}
if cfg.Token != "tok" || cfg.API != "https://tunnel.example" || cfg.Routes["api"].Target != "127.0.0.1:4096" {
t.Errorf("config = %+v", cfg)
}
if _, err := os.Stat(ConfigPaths(target).Key); err != nil {
t.Errorf("key not copied: %v", err)
}
}
func TestProxyHeaders(t *testing.T) {
if got := string(proxyV1("203.0.113.9:51234")); got != "PROXY TCP4 203.0.113.9 0.0.0.0 51234 443\r\n" {
t.Errorf("v1 = %q", got)
}
if got := string(proxyV1("nonsense")); got != "PROXY UNKNOWN\r\n" {
t.Errorf("v1 unknown = %q", got)
}
v2 := proxyV2("203.0.113.9:51234", "api.example")
sig := "\r\n\r\n\x00\r\nQUIT\n"
if !strings.HasPrefix(string(v2), sig) {
t.Fatalf("v2 signature missing")
}
if v2[12] != 0x21 || v2[13] != 0x11 {
t.Errorf("v2 version/family = %x %x", v2[12], v2[13])
}
if !strings.HasSuffix(string(v2), "api.example") {
t.Errorf("v2 TLV missing")
}
unknown := proxyV2("nonsense", "")
if unknown[12] != 0x20 {
t.Errorf("v2 local command = %x", unknown[12])
}
}
+260
View File
@@ -0,0 +1,260 @@
// 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"`
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"),
}
}
+200
View File
@@ -0,0 +1,200 @@
package client
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// ErrCertificateExpired means a cached certificate can no longer be served.
var ErrCertificateExpired = errors.New("certificate expired")
// EnsureIdentity loads (or creates) the tunnel's private key and CSR. The CSR
// is reused byte-for-byte when it matches hostname, because binding a different
// CSR makes the server issue a new certificate.
func EnsureIdentity(paths Paths, hostname string) (keyPEM, csrPEM []byte, err error) {
if err := os.MkdirAll(paths.Dir, 0o700); err != nil {
return nil, nil, err
}
keyPEM, err = os.ReadFile(paths.Key)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, nil, err
}
var key *ecdsa.PrivateKey
if len(keyPEM) > 0 {
key, err = parseKey(keyPEM)
if err != nil {
return nil, nil, fmt.Errorf("private key: %w", err)
}
}
if key == nil {
key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, err
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := writeAtomic(paths.Key, keyPEM, 0o600); err != nil {
return nil, nil, err
}
}
csrPEM, err = os.ReadFile(paths.CSR)
if err == nil && csrMatches(csrPEM, hostname) {
return keyPEM, csrPEM, nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, nil, err
}
csrPEM, err = generateCSR(key, hostname)
if err != nil {
return nil, nil, err
}
if err := writeAtomic(paths.CSR, csrPEM, 0o600); err != nil {
return nil, nil, err
}
return keyPEM, csrPEM, nil
}
func parseKey(keyPEM []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(keyPEM)
if block == nil {
return nil, errors.New("not a PEM private key")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
key, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("not an ECDSA private key")
}
if key.Curve != elliptic.P256() {
return nil, errors.New("not a P-256 private key")
}
return key, nil
}
func generateCSR(key *ecdsa.PrivateKey, hostname string) ([]byte, error) {
request := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: hostname},
DNSNames: []string{hostname, "*." + hostname},
}
der, err := x509.CreateCertificateRequest(rand.Reader, request, key)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}), nil
}
func csrMatches(csrPEM []byte, hostname string) bool {
block, _ := pem.Decode(csrPEM)
if block == nil || block.Type != "CERTIFICATE REQUEST" {
return false
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil || csr.CheckSignature() != nil {
return false
}
if csr.Subject.CommonName != hostname {
return false
}
have := map[string]bool{}
for _, name := range csr.DNSNames {
have[name] = true
}
return have[hostname] && have["*."+hostname]
}
// LoadCertificate loads cert.pem/chain.pem/private-key.pem and checks that the
// certificate still covers hostname and has not expired.
func LoadCertificate(paths Paths, hostname string) (tls.Certificate, time.Time, error) {
keyPEM, err := os.ReadFile(paths.Key)
if err != nil {
return tls.Certificate{}, time.Time{}, err
}
certPEM, err := os.ReadFile(paths.Cert)
if err != nil {
return tls.Certificate{}, time.Time{}, err
}
chainPEM, err := os.ReadFile(paths.Chain)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return tls.Certificate{}, time.Time{}, err
}
return parseCertificate(append(certPEM, chainPEM...), keyPEM, hostname)
}
// SaveCertificate validates and stores a certificate from the API.
func SaveCertificate(paths Paths, certPEM, chainPEM, keyPEM []byte, hostname string) (tls.Certificate, time.Time, error) {
fullchain := append(append([]byte{}, certPEM...), chainPEM...)
cert, expiry, err := parseCertificate(fullchain, keyPEM, hostname)
if err != nil {
return tls.Certificate{}, time.Time{}, err
}
if err := writeAtomic(paths.Cert, certPEM, 0o644); err != nil {
return tls.Certificate{}, time.Time{}, err
}
if err := writeAtomic(paths.Chain, chainPEM, 0o644); err != nil {
return tls.Certificate{}, time.Time{}, err
}
return cert, expiry, nil
}
func parseCertificate(fullchainPEM, keyPEM []byte, hostname string) (tls.Certificate, time.Time, error) {
cert, err := tls.X509KeyPair(fullchainPEM, keyPEM)
if err != nil {
return tls.Certificate{}, time.Time{}, fmt.Errorf("certificate/key: %w", err)
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return tls.Certificate{}, time.Time{}, err
}
if err := leaf.VerifyHostname(hostname); err != nil {
return tls.Certificate{}, time.Time{}, fmt.Errorf("certificate does not cover %s: %w", hostname, err)
}
if time.Now().After(leaf.NotAfter) {
return tls.Certificate{}, time.Time{}, ErrCertificateExpired
}
return cert, leaf.NotAfter, nil
}
// CertificateExpiry parses the API's RFC 3339 expiry string.
func CertificateExpiry(value string) (time.Time, error) {
if value == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, value)
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".tunnel-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
defer os.Remove(name)
if err := tmp.Chmod(mode); 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)
}
+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)
}
+92
View File
@@ -0,0 +1,92 @@
package client
import (
"fmt"
"io"
"net"
"strconv"
)
// writeProxyHeader writes the optional PROXY protocol header to a local
// target. The destination is the unspecified address of the source family on
// port 443, matching the reference clients (the real public address is not
// known to the client).
func writeProxyHeader(w io.Writer, version, peer, sni string) error {
switch version {
case "v1":
_, err := w.Write(proxyV1(peer))
return err
case "v2":
_, err := w.Write(proxyV2(peer, sni))
return err
default:
return fmt.Errorf("unknown proxy protocol %q", version)
}
}
func proxyV1(peer string) []byte {
ip, port, ok := parsePeer(peer)
if !ok {
return []byte("PROXY UNKNOWN\r\n")
}
if v4 := ip.To4(); v4 != nil {
return []byte(fmt.Sprintf("PROXY TCP4 %s 0.0.0.0 %d 443\r\n", v4, port))
}
return []byte(fmt.Sprintf("PROXY TCP6 %s :: %d 443\r\n", ip, port))
}
func proxyV2(peer, sni string) []byte {
signature := []byte("\r\n\r\n\x00\r\nQUIT\n")
ip, port, ok := parsePeer(peer)
command := byte(0x21) // PROXY
family := byte(0x00) // UNSPEC
var address []byte
if ok {
if v4 := ip.To4(); v4 != nil {
family = 0x11 // TCP4
address = append(address, v4...)
address = append(address, 0, 0, 0, 0)
} else {
family = 0x21 // TCP6
address = append(address, ip.To16()...)
address = append(address, make([]byte, 16)...)
}
address = append(address, byte(port>>8), byte(port), 0x01, 0xbb) // destination port 443
} else {
command = 0x20 // LOCAL
}
var tlv []byte
if sni != "" && len(sni) <= 255 {
tlv = []byte{0x02, 0x00, byte(len(sni))}
tlv = append(tlv, sni...)
}
length := len(address) + len(tlv)
header := make([]byte, 0, len(signature)+4+length)
header = append(header, signature...)
header = append(header, command, family, byte(length>>8), byte(length))
header = append(header, address...)
header = append(header, tlv...)
return header
}
func parsePeer(peer string) (net.IP, int, bool) {
host, portText, err := net.SplitHostPort(peer)
if err != nil {
host, portText = peer, "0"
}
ip := net.ParseIP(host)
if ip == nil {
return nil, 0, false
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
port, _ := strconv.Atoi(portText)
if port < 0 || port > 65535 {
port = 0
}
return ip, port, true
}
+416
View File
@@ -0,0 +1,416 @@
package client
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
)
// Service is one provisioning + serving lifecycle for a config file.
type Service struct {
ConfigPath string
Paths Paths
Config *Config
API *APIClient
Log *log.Logger
Version string
cert atomic.Pointer[tls.Certificate]
mu sync.RWMutex // guards Config and API during the renewal poller
}
// NewService loads a config (which may not exist yet).
func NewService(configPath, version string, logger *log.Logger) (*Service, error) {
cfg, err := Load(configPath)
if err != nil {
return nil, err
}
if logger == nil {
logger = log.New(os.Stderr, "", log.LstdFlags)
}
s := &Service{
ConfigPath: configPath,
Paths: ConfigPaths(configPath),
Config: cfg,
Log: logger,
Version: version,
}
if cfg.API != "" {
s.API = NewAPIClient(cfg.API)
}
return s, nil
}
// Ensure creates the tunnel if needed, makes the local key/CSR, and makes
// sure a usable certificate is cached.
func (s *Service) Ensure(ctx context.Context) error {
cfg, err := Load(s.ConfigPath)
if err != nil {
return err
}
if cfg.API == "" {
return errors.New("no API configured; run: tunnel config set-api https://your-server")
}
s.Paths = ConfigPaths(s.ConfigPath)
s.mu.Lock()
s.Config = cfg
s.API = NewAPIClient(cfg.API)
s.mu.Unlock()
if cfg.TunnelID == "" || cfg.Token == "" {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
tunnel, token, err := s.API.CreateTunnel(ctx)
cancel()
if err != nil {
return fmt.Errorf("create tunnel: %w", err)
}
cfg.TunnelID, cfg.Hostname, cfg.Token = tunnel.ID, tunnel.Hostname, token
// Persist the identity before binding, so an interrupted provision
// can resume with the same token and hostname.
if err := cfg.Save(s.ConfigPath); err != nil {
return err
}
s.Log.Printf("created tunnel %s (%s)", cfg.TunnelID, cfg.Hostname)
}
info, err := s.API.GetTunnel(ctx, cfg.TunnelID, cfg.Token)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == 404 {
return fmt.Errorf("tunnel %s no longer exists on the server", cfg.TunnelID)
}
if errors.As(err, &apiErr) && apiErr.Status == 401 {
return fmt.Errorf("the server rejected the saved token for tunnel %s", cfg.TunnelID)
}
return fmt.Errorf("get tunnel: %w", err)
}
if info.Hostname != "" && info.Hostname != cfg.Hostname {
cfg.Hostname = info.Hostname
if err := cfg.Save(s.ConfigPath); err != nil {
return err
}
}
if cfg.Hostname == "" {
return errors.New("server did not return a tunnel hostname")
}
keyPEM, csrPEM, err := EnsureIdentity(s.Paths, cfg.Hostname)
if err != nil {
return err
}
return s.ensureCertificate(ctx, keyPEM, csrPEM)
}
func (s *Service) ensureCertificate(ctx context.Context, keyPEM, csrPEM []byte) error {
cfg := s.Config
localCert, localExpiry, localErr := LoadCertificate(s.Paths, cfg.Hostname)
info, err := s.API.GetCertificate(ctx, cfg.TunnelID, cfg.Token)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == 404 {
return s.bindAndWait(ctx, keyPEM, csrPEM)
}
if localErr == nil {
s.Log.Printf("certificate check failed (%v); using saved certificate expiring %s", err, localExpiry.Format(time.RFC3339))
s.cert.Store(&localCert)
return nil
}
return fmt.Errorf("get certificate: %w", err)
}
switch info.State.Type {
case "ready":
return s.useReadyCertificate(keyPEM, localCert, localExpiry, localErr, info)
case "failed":
return s.bindAndWait(ctx, keyPEM, csrPEM)
case "issuing":
return s.waitForCertificate(ctx, keyPEM)
default:
return s.bindAndWait(ctx, keyPEM, csrPEM)
}
}
func (s *Service) useReadyCertificate(keyPEM []byte, localCert tls.Certificate, localExpiry time.Time, localErr error, info CertificateInfo) error {
serverExpiry, err := CertificateExpiry(info.State.Expiry)
if err != nil {
return fmt.Errorf("server certificate expiry: %w", err)
}
if localErr == nil && !localExpiry.Before(serverExpiry) && sameExpiry(s.Config.CertificateExpiry, serverExpiry) {
s.cert.Store(&localCert)
return nil
}
cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, s.Config.Hostname)
if err != nil {
return fmt.Errorf("save certificate: %w", err)
}
s.cert.Store(&cert)
s.setExpiry(expiry)
s.Log.Printf("certificate ready (expires %s)", expiry.Format(time.RFC3339))
return nil
}
func (s *Service) bindAndWait(ctx context.Context, keyPEM, csrPEM []byte) error {
s.Log.Printf("requesting certificate for %s", s.Config.Hostname)
info, err := s.API.BindCertificate(ctx, s.Config.TunnelID, s.Config.Token, string(csrPEM))
if err != nil {
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Status != 409 {
return fmt.Errorf("bind certificate: %w", err)
}
}
if info.State.Type == "ready" {
return s.storeReady(keyPEM, info)
}
if info.State.Type == "failed" {
return fmt.Errorf("certificate issuance failed: %s", info.State.Reason)
}
return s.waitForCertificate(ctx, keyPEM)
}
func (s *Service) waitForCertificate(ctx context.Context, keyPEM []byte) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
info, err := s.API.GetCertificate(ctx, s.Config.TunnelID, s.Config.Token)
if err != nil {
return fmt.Errorf("poll certificate: %w", err)
}
switch info.State.Type {
case "ready":
return s.storeReady(keyPEM, info)
case "failed":
return fmt.Errorf("certificate issuance failed: %s", info.State.Reason)
}
select {
case <-ctx.Done():
return fmt.Errorf("certificate not ready: %w", ctx.Err())
case <-ticker.C:
}
}
}
func (s *Service) storeReady(keyPEM []byte, info CertificateInfo) error {
cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, s.Config.Hostname)
if err != nil {
return fmt.Errorf("save certificate: %w", err)
}
s.cert.Store(&cert)
s.setExpiry(expiry)
s.Log.Printf("certificate ready (expires %s)", expiry.Format(time.RFC3339))
return nil
}
func (s *Service) setExpiry(expiry time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.Config.CertificateExpiry = expiry.UTC().Format(time.RFC3339)
if err := s.Config.Save(s.ConfigPath); err != nil {
s.Log.Printf("save config: %v", err)
}
}
func sameExpiry(stored string, expiry time.Time) bool {
parsed, err := CertificateExpiry(stored)
return err == nil && parsed.Equal(expiry)
}
// RefreshCertificate checks the server for a renewed certificate. A transient
// failure leaves the current certificate serving.
func (s *Service) RefreshCertificate(ctx context.Context) {
s.mu.RLock()
cfg := *s.Config
api := s.API
s.mu.RUnlock()
info, err := api.GetCertificate(ctx, cfg.TunnelID, cfg.Token)
if err != nil {
s.Log.Printf("certificate refresh check: %v", err)
return
}
if info.State.Type != "ready" {
if info.State.Type == "failed" {
s.Log.Printf("certificate renewal failed on the server: %s", info.State.Reason)
}
return
}
expiry, err := CertificateExpiry(info.State.Expiry)
if err != nil {
s.Log.Printf("certificate expiry: %v", err)
return
}
if sameExpiry(cfg.CertificateExpiry, expiry) {
return
}
keyPEM, err := os.ReadFile(s.Paths.Key)
if err != nil {
s.Log.Printf("read key: %v", err)
return
}
cert, expiry, err := SaveCertificate(s.Paths, []byte(info.State.Certificate), []byte(info.State.Chain), keyPEM, cfg.Hostname)
if err != nil {
s.Log.Printf("save renewed certificate: %v", err)
return
}
s.cert.Store(&cert)
s.setExpiry(expiry)
s.Log.Printf("certificate renewed (expires %s)", expiry.Format(time.RFC3339))
}
func (s *Service) refreshLoop(ctx context.Context) {
for {
interval := 12 * time.Hour
s.mu.RLock()
expiryText := s.Config.CertificateExpiry
s.mu.RUnlock()
if expiry, err := CertificateExpiry(expiryText); err == nil {
if time.Until(expiry) < 30*24*time.Hour {
interval = time.Minute
}
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
s.RefreshCertificate(ctx)
}
}
}
// Run keeps the bridge connected, reloading routes when the config changes.
func (s *Service) Run(ctx context.Context) error {
if err := s.Ensure(ctx); err != nil {
return err
}
go s.refreshLoop(ctx)
reload := make(chan struct{}, 1)
go watchConfig(ctx, s.ConfigPath, reload)
delay := 250 * time.Millisecond
for {
cfg, err := s.reload(ctx)
if err != nil {
s.Log.Printf("config: %v", err)
if !wait(ctx, delay) {
return ctx.Err()
}
delay = nextBackoff(delay)
continue
}
sess, err := s.connect(ctx, cfg)
if err != nil {
s.Log.Printf("bridge: %v", err)
if errors.Is(err, errFatalAttach) {
return err
}
if !wait(ctx, delay) {
return ctx.Err()
}
delay = nextBackoff(delay)
continue
}
delay = 250 * time.Millisecond
select {
case <-ctx.Done():
sess.close("shutdown")
return ctx.Err()
case <-reload:
s.Log.Printf("config changed; reconnecting")
sess.close("config changed")
case <-sess.done:
}
}
}
func (s *Service) reload(ctx context.Context) (*Config, error) {
cfg, err := Load(s.ConfigPath)
if err != nil {
return nil, err
}
if cfg.API == "" {
return nil, errors.New("no API configured; run: tunnel config set-api https://your-server")
}
if cfg.TunnelID == "" || cfg.Token == "" || cfg.Hostname == "" || s.cert.Load() == nil {
if err := s.Ensure(ctx); err != nil {
return nil, err
}
s.mu.RLock()
cfg = s.Config
s.mu.RUnlock()
}
s.mu.Lock()
s.Config = cfg
s.API = NewAPIClient(cfg.API)
s.mu.Unlock()
return cfg, nil
}
// TLSConfig serves the current certificate and supports hot renewal.
func (s *Service) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert := s.cert.Load()
if cert == nil {
return nil, errors.New("no certificate loaded")
}
return cert, nil
},
}
}
func watchConfig(ctx context.Context, path string, changed chan<- struct{}) {
var last time.Time
if info, err := os.Stat(path); err == nil {
last = info.ModTime()
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
info, err := os.Stat(path)
if err != nil || !info.ModTime().After(last) {
continue
}
last = info.ModTime()
select {
case changed <- struct{}{}:
default:
}
}
}
}
func wait(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextBackoff(d time.Duration) time.Duration {
d *= 2
if d > 30*time.Second {
return 30 * time.Second
}
return d
}