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:
+106
-19
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user