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
+106 -19
View File
@@ -166,7 +166,8 @@ type Bridge struct {
clientVersion string
startedAt time.Time
outbox chan outbound
control chan outbound
dataQ chan outbound
done chan struct{}
closeOnce sync.Once
@@ -244,7 +245,8 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
ws: ws,
clientVersion: attach.Client.Version,
startedAt: time.Now(),
outbox: make(chan outbound, 256),
control: make(chan outbound, 64),
dataQ: make(chan outbound, 256),
done: make(chan struct{}),
conns: map[uint32]*visitorConn{},
}
@@ -295,13 +297,13 @@ func (b *Bridge) openVisitor(conn net.Conn, hello []byte, peer, sni, alpn string
open, _ := json.Marshal(openMessage{
Type: "open", Conn: id, Peer: peer, SNI: sni, ALPN: alpn,
})
if !b.send(websocket.MessageText, open) {
if !b.sendControl(websocket.MessageText, open) {
b.removeConn(id, true)
return
}
for len(hello) > 0 {
n := min(len(hello), maxPayload)
if !b.send(websocket.MessageBinary, buildFrame(id, hello[:n])) {
if !b.sendData(websocket.MessageBinary, buildFrame(id, hello[:n])) {
b.removeConn(id, true)
return
}
@@ -317,7 +319,7 @@ func (b *Bridge) pump(v *visitorConn) {
n, err := v.conn.Read(buf)
v.touch()
if n > 0 {
if !b.send(websocket.MessageBinary, buildFrame(v.id, buf[:n])) {
if !b.sendData(websocket.MessageBinary, buildFrame(v.id, buf[:n])) {
return
}
}
@@ -332,15 +334,17 @@ func (b *Bridge) pump(v *visitorConn) {
}
if err == io.EOF {
end, _ := json.Marshal(endMessage{Type: "end", Conn: v.id})
b.send(websocket.MessageText, end)
b.sendData(websocket.MessageText, end)
} else {
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: v.id, Code: ResetIOError})
b.send(websocket.MessageText, reset)
b.sendControl(websocket.MessageText, reset)
b.removeConn(v.id, true)
return
}
if bothEnded {
b.removeConn(v.id, false)
} else {
b.scheduleHalfClose(v.id, v)
}
return
}
@@ -387,7 +391,7 @@ func (b *Bridge) handleControl(raw []byte) {
switch msg.Type {
case "ping":
pong, _ := json.Marshal(pingPongMessage{Type: "pong", TimeSent: msg.TimeSent})
b.send(websocket.MessageText, pong)
b.sendControl(websocket.MessageText, pong)
case "end":
if msg.Conn != nil {
b.endFromClient(*msg.Conn)
@@ -416,19 +420,48 @@ func (b *Bridge) endFromClient(connID uint32) {
}
if bothEnded {
b.removeConn(connID, false)
} else {
b.scheduleHalfClose(connID, v)
}
}
// writeLoop is the only writer on the WebSocket, keeping frames ordered.
// writeLoop is the only writer on the WebSocket. Control messages get
// priority over queued visitor data; `end` messages travel in the data queue
// so they stay behind the final bytes of a response.
func (b *Bridge) writeLoop() {
ctx := context.Background()
write := func(msg outbound) bool {
if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil {
b.close(websocket.StatusInternalError, "write failed")
return false
}
return true
}
for {
select {
case <-b.done:
return
case msg := <-b.outbox:
if err := writeWS(ctx, b.ws, msg.typ, msg.data); err != nil {
b.close(websocket.StatusInternalError, "write failed")
default:
}
select {
case <-b.done:
return
case msg := <-b.control:
if !write(msg) {
return
}
continue
default:
}
select {
case <-b.done:
return
case msg := <-b.control:
if !write(msg) {
return
}
case msg := <-b.dataQ:
if !write(msg) {
return
}
}
@@ -459,28 +492,52 @@ func (b *Bridge) sweeper() {
b.mu.Unlock()
for _, id := range stale {
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout})
b.send(websocket.MessageText, reset)
b.sendControl(websocket.MessageText, reset)
b.removeConn(id, true)
}
}
}
}
// send queues a frame, applying backpressure when the client is slow.
// It returns false when the bridge is gone.
func (b *Bridge) send(typ websocket.MessageType, data []byte) bool {
// sendControl queues a control frame. It never blocks behind visitor data; a
// stalled client whose control queue is full is closed.
func (b *Bridge) sendControl(typ websocket.MessageType, data []byte) bool {
select {
case <-b.done:
return false
case b.outbox <- outbound{typ, data}:
default:
}
select {
case b.control <- outbound{typ, data}:
return true
case <-b.done:
return false
default:
b.close(websocket.StatusInternalError, "control queue full")
return false
}
}
// trySend queues a frame only if it fits immediately.
// sendData queues visitor bytes, blocking (and thus backpressuring the visitor
// socket) when the client is slow.
func (b *Bridge) sendData(typ websocket.MessageType, data []byte) bool {
select {
case <-b.done:
return false
default:
}
select {
case b.dataQ <- outbound{typ, data}:
return true
case <-b.done:
return false
}
}
// trySend queues a control frame only if it fits immediately.
func (b *Bridge) trySend(typ websocket.MessageType, data []byte) {
select {
case b.outbox <- outbound{typ, data}:
case b.control <- outbound{typ, data}:
default:
}
}
@@ -490,6 +547,10 @@ func (b *Bridge) removeConn(id uint32, rst bool) {
b.mu.Lock()
v := b.conns[id]
delete(b.conns, id)
if v != nil && v.halfClose != nil {
v.halfClose.Stop()
v.halfClose = nil
}
b.mu.Unlock()
if v == nil {
return
@@ -502,6 +563,31 @@ func (b *Bridge) removeConn(id uint32, rst bool) {
v.conn.Close()
}
// halfCloseTimeout bounds how long a connection may stay half-closed before
// it is reset, so a peer that never closes its side cannot pin a connection.
const halfCloseTimeout = 60 * time.Second
// scheduleHalfClose resets a connection whose one side ended but whose other
// side has not, after halfCloseTimeout.
func (b *Bridge) scheduleHalfClose(id uint32, v *visitorConn) {
b.mu.Lock()
defer b.mu.Unlock()
if v.halfClose != nil || (v.visitorEnded && v.clientEnded) || b.conns[id] != v {
return
}
v.halfClose = time.AfterFunc(halfCloseTimeout, func() {
b.mu.Lock()
stillKnown := b.conns[id] == v
b.mu.Unlock()
if !stillKnown {
return
}
reset, _ := json.Marshal(resetMessage{Type: "reset", Conn: id, Code: ResetIdleTimeout})
b.sendControl(websocket.MessageText, reset)
b.removeConn(id, true)
})
}
func (b *Bridge) touch() {
b.lastActivity.Store(time.Now().UnixMilli())
}
@@ -531,6 +617,7 @@ type visitorConn struct {
visitorEnded bool // pump saw EOF: no more visitor data
clientEnded bool // client sent end: target will send no more
halfClose *time.Timer
lastActivity atomic.Int64
}