M1: single-node OpenTunnel-compatible server
HTTP API per the recorded contract, bridge WebSocket, SNI relay with pass-through tunnel TLS, local CA issuance, JSON store. Verified end-to-end with the official opentunnel CLI.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user