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.
649 lines
15 KiB
Go
649 lines
15 KiB
Go
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)
|
|
}
|