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
+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)
}