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.
230 lines
6.0 KiB
Go
230 lines
6.0 KiB
Go
// Package server implements a single-node OpenTunnel-compatible server:
|
|
// the HTTP API, the bridge WebSocket, and SNI-based relaying described in
|
|
// docs/protocol.md of the reference implementation.
|
|
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"regexp"
|
|
)
|
|
|
|
// Identifiers are generated exactly as the reference does
|
|
// (crates/opentunnel-server/src/crypto.rs): a tunnel ID is 12 random base32
|
|
// characters, a token is "rly_" plus 32 random bytes in base64url, and the
|
|
// stored form of a token is the lowercase hex of its SHA-256.
|
|
|
|
const slugAlphabet = "abcdefghijklmnopqrstuvwxyz234567"
|
|
|
|
func randomBytes(n int) []byte {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func slug() string {
|
|
b := randomBytes(12)
|
|
for i := range b {
|
|
b[i] = slugAlphabet[b[i]&31]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func newToken() string {
|
|
return "rly_" + base64.RawURLEncoding.EncodeToString(randomBytes(32))
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func tokenMatches(token, hash string) bool {
|
|
actual := hashToken(token)
|
|
return subtle.ConstantTimeCompare([]byte(actual), []byte(hash)) == 1
|
|
}
|
|
|
|
func certID() string { return "cert_" + base64.RawURLEncoding.EncodeToString(randomBytes(12)) }
|
|
func sessionID() string { return "sess_" + base64.RawURLEncoding.EncodeToString(randomBytes(12)) }
|
|
|
|
// Route names: "@" for the tunnel hostname itself, or one DNS label.
|
|
var routeLabel = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
|
|
func validRoute(name string) bool {
|
|
return name == "@" || routeLabel.MatchString(name)
|
|
}
|
|
|
|
// ---- HTTP API wire types. Field order matters: the recorded contract
|
|
// (worker-contract.json) pins the key order of every body.
|
|
|
|
type TunnelInfo struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname"`
|
|
State string `json:"state"` // "offline" | "online"
|
|
CertificateID string `json:"certificateID,omitempty"`
|
|
}
|
|
|
|
type createResponse struct {
|
|
Tunnel TunnelInfo `json:"tunnel"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type CertState struct {
|
|
Type string `json:"type"`
|
|
Token string `json:"token,omitempty"` // challenge
|
|
Key string `json:"key,omitempty"` // challenge
|
|
Certificate string `json:"certificate,omitempty"` // ready
|
|
Chain string `json:"chain,omitempty"` // ready
|
|
Expiry string `json:"expiry,omitempty"` // ready
|
|
Reason string `json:"reason,omitempty"` // failed
|
|
}
|
|
|
|
type CertificateInfo struct {
|
|
ID string `json:"id"`
|
|
State CertState `json:"state"`
|
|
}
|
|
|
|
// Error bodies. The reference emits {"_tag": ..., extra..., "message": ...}.
|
|
type errUnauthorized struct {
|
|
Tag string `json:"_tag"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// errRateLimited is the 429 body used by the reference's limits.
|
|
type errRateLimited struct {
|
|
Tag string `json:"_tag"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type errTaggedID struct {
|
|
Tag string `json:"_tag"`
|
|
TunnelID string `json:"tunnelID"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type errInvalidRequest struct {
|
|
Tag string `json:"_tag"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type errInvalidHostname struct {
|
|
Tag string `json:"_tag"`
|
|
Provided string `json:"provided"`
|
|
Expected string `json:"expected"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ---- Bridge control messages.
|
|
|
|
const (
|
|
BridgeSubprotocol = "opentunnel"
|
|
heartbeatMs = 15000
|
|
idleTimeoutMs = 45000
|
|
maxPayload = 32768 // bytes per binary data frame
|
|
maxRoutesPerAttach = 128
|
|
maxConnsPerBridge = 256
|
|
)
|
|
|
|
// attachMessage is the one client message with required structure; all other
|
|
// client control messages are decoded loosely and unknown types ignored.
|
|
type attachMessage 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"`
|
|
}
|
|
|
|
// clientControl decodes the non-attach client messages.
|
|
type clientControl struct {
|
|
Type string `json:"type"`
|
|
Conn *uint32 `json:"conn"`
|
|
Code string `json:"code"`
|
|
TimeSent int64 `json:"time_sent"`
|
|
}
|
|
|
|
func parseClientControl(text []byte) (clientControl, error) {
|
|
var msg clientControl
|
|
if err := json.Unmarshal(text, &msg); err != nil {
|
|
return msg, err
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// Server-to-client messages, in pinned key order.
|
|
|
|
type attachedMessage struct {
|
|
Type string `json:"type"`
|
|
Session string `json:"session"`
|
|
Routes []string `json:"routes"`
|
|
HeartbeatMs int `json:"heartbeat_ms"`
|
|
IdleTimeoutMs int `json:"idle_timeout_ms"`
|
|
}
|
|
|
|
type attachErrorMessage struct {
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type openMessage struct {
|
|
Type string `json:"type"`
|
|
Conn uint32 `json:"conn"`
|
|
Peer string `json:"peer"`
|
|
SNI string `json:"sni"`
|
|
ALPN string `json:"alpn"`
|
|
}
|
|
|
|
type pingPongMessage struct {
|
|
Type string `json:"type"`
|
|
TimeSent int64 `json:"time_sent"`
|
|
}
|
|
|
|
type drainMessage struct {
|
|
Type string `json:"type"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type endMessage struct {
|
|
Type string `json:"type"`
|
|
Conn uint32 `json:"conn"`
|
|
}
|
|
|
|
type resetMessage struct {
|
|
Type string `json:"type"`
|
|
Conn uint32 `json:"conn"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// Reset codes used by this server (clients treat codes as opaque strings).
|
|
const (
|
|
ResetIdleTimeout = "idle_timeout"
|
|
ResetIOError = "io_error"
|
|
ResetDrain = "drain"
|
|
)
|
|
|
|
// buildFrame serializes a binary data frame: big-endian u32 conn + payload.
|
|
func buildFrame(conn uint32, payload []byte) []byte {
|
|
frame := make([]byte, 4+len(payload))
|
|
binary.BigEndian.PutUint32(frame, conn)
|
|
copy(frame[4:], payload)
|
|
return frame
|
|
}
|
|
|
|
// parseFrame splits a binary data frame into conn ID and payload.
|
|
func parseFrame(data []byte) (uint32, []byte, bool) {
|
|
if len(data) < 4 {
|
|
return 0, nil, false
|
|
}
|
|
return binary.BigEndian.Uint32(data), data[4:], true
|
|
}
|