# BetterDesk Go Server โ€” Clean-Room Implementation Context > This file is automatically included in every Copilot conversation context. > It contains the complete specification for building a clean-room RustDesk-compatible server in Go. --- ## ๐ŸŽฏ Project Goal Build a **clean-room** signal + relay server in **Go** that is 100% compatible with existing RustDesk clients (desktop, mobile, web) but completely independent of the AGPL-3.0 RustDesk server codebase. The result is a single binary called **`betterdesk-server`** that replaces both `hbbs` (signal) and `hbbr` (relay). ### Legal Basis - Do not infer the provenance or copyright status of `.proto` files from their protocol role or file headers. Existing schemas are subject to the provenance gate in `docs/important/support-agent-provenance.md`. - Do not copy external source, generated artifacts, comments, or test fixtures. Implement only from BetterDesk-owned specifications and independently authored black-box test vectors. - Do not make clean-room or relicensing claims in code, documentation, or release notes until the provenance register has been reviewed. --- ## ๐Ÿ“Š Current Status | ID | Task | Status | Notes | |----|------|--------|-------| | 0.1 | Create Go project skeleton (`betterdesk-server/`) | โœ… done | `go mod init`, folder structure | | 0.2 | Compile protobuf โ†’ Go structs | โœ… done | `rendezvous.pb.go` + `message.pb.go` (12K LOC) | | 0.3 | Implement AddrMangle (encode/decode) | โœ… done | `crypto/addr_mangle.go` + tests | | 0.4 | Implement framing codec (TCP) | โœ… done | `codec/` โ€” 2-byte BE length prefix + protobuf | | 0.5 | Implement Ed25519 key management | โœ… done | `crypto/keys.go` + `crypto/secure.go` (NaCl) | | 0.6 | Implement UDP signal listener (port 21116) | โœ… done | `signal/server.go` โ€” RegisterPeer, PunchHole, etc. | | 0.7 | Implement TCP signal listener (port 21116) | โœ… done | `signal/server.go` โ€” NaCl key exchange + fallback | | 0.8 | Implement NAT test listener (port 21115) | โœ… done | `signal/handler.go` โ€” TestNatRequest/Response | | 0.9 | Implement WebSocket signal (port 21118) | โœ… done | `signal/ws.go` โ€” coder/websocket | | 0.10 | Implement relay (TCP port 21117) | โœ… done | `relay/server.go` โ€” UUID pairing + io.Copy | | 0.11 | Implement relay WebSocket (port 21119) | โœ… done | `relay/ws.go` โ€” websocket relay | | 0.12 | Implement HTTP API (port 21114) | โœ… done | `api/server.go` + `api/auth_handlers.go` (1493 LOC) | | 0.13 | Implement SQLite database layer | โœ… done | `db/sqlite.go` (702 LOC) โ€” full `Database` interface | | 0.14 | Implement peer map (in-memory) | โœ… done | `peer/map.go` (469 LOC) โ€” concurrent with heartbeat | | 0.15 | Implement ban system | โœ… done | Checked at registration, relay, and API | | 0.16 | Implement ID change | โœ… done | `RegisterPk.old_id` + API endpoint + history | | 0.17 | Implement bandwidth limiting | โœ… done | `ratelimit/bandwidth.go` โ€” token bucket | | 0.18 | Implement blacklist/blocklist | โœ… done | `security/blocklist.go` โ€” IP/ID/CIDR | | 0.19 | Integration tests with real RustDesk client | โœ… done | Desktop + web client tested | | 0.20 | Cross-compile (Linux amd64, arm64, Windows) | โœ… done | development builds available | ### Phase 2 โ€” Security & Protocol Hardening (NEW) | ID | Task | Status | Notes | |----|------|--------|-------| | 1.1 | **H1**: Validate `new_id` in change-id API | โœ… done | `peerIDRegexp` check added in `api/server.go` | | 1.2 | **H3**: Rate-limit on `/api/auth/login/2fa` | โœ… done | `loginLimiter.Allow(clientIP)` + audit log | | 1.3 | **H4**: Short TTL for partial 2FA token | โœ… done | `GenerateWithTTL()` 5min in `auth/jwt.go` | | 1.4 | **M1**: Escape `%`/`_` in SQL LIKE patterns | โœ… done | `ESCAPE '\'` clause in `db/sqlite.go` | | 1.5 | **M4**: Rate-limit TCP signal connections | โœ… done | `limiter.Allow(host)` in `serveTCP()` | | 1.6 | **M6**: Validate config key names | โœ… done | `configKeyRegexp` in `api/server.go` | | 1.7 | **M8**: `ConfigUpdate` in `TestNatResponse` | โœ… done | `Cu` field with relay/rendezvous servers | | 1.8 | **M2**: TTL for `tcpPunchConns` sync.Map | โœ… done | 2min TTL + 10K cap + cleanup goroutine | | 1.9 | **M3**: WebSocket origin validation | โœ… done | `WS_ALLOWED_ORIGINS` env var + `OriginPatterns` | | 1.10 | **M7**: Relay idle timeout | โœ… done | `idleTimeoutConn` wrapper extending deadline on R/W | ### Phase 3 โ€” TLS Everywhere โœ… COMPLETED 2026-02-28 | ID | Task | Status | Notes | |----|------|--------|-------| | 2.1 | TLS for TCP signal (:21116) | โœ… done | `DualModeListener` auto-detects plain vs TLS | | 2.2 | TLS for TCP relay (:21117) | โœ… done | Same `DualModeListener` pattern | | 2.3 | WSS for signal + relay | โœ… done | `ListenAndServeTLS` with shared `LoadTLSConfig` | | 2.4 | Auto-detect plain/TLS on same port | โœ… done | First-byte `0x16` detection (`peekedConn`) | | 2.5 | Config flags + env vars | โœ… done | `--tls-signal`, `--tls-relay` + `TLS_SIGNAL=Y`, `TLS_RELAY=Y` | ### Phase 4 โ€” PostgreSQL Integration (NEW) | ID | Task | Status | Notes | |----|------|--------|-------| | 3.1 | `db/postgres.go` implementation | โฌœ todo | Full `Database` interface via `pgx/v5` | | 3.2 | `db/open.go` dispatcher | โฌœ todo | Detect `postgres://` DSN | | 3.3 | Connection pooling (`pgxpool`) | โฌœ todo | Configurable max conns | | 3.4 | PostgreSQL schema + types | โฌœ todo | BOOLEAN, BYTEA, TIMESTAMPTZ | | 3.5 | `LISTEN/NOTIFY` events | โฌœ todo | Real-time push between instances | | 3.6 | Integration tests | โฌœ todo | PostgreSQL backend tests | ### Phase 5 โ€” Migration Tool (NEW) | ID | Task | Status | Notes | |----|------|--------|-------| | 4.1 | `tools/migrate/main.go` | โฌœ todo | CLI migration binary | | 4.2 | Original RustDesk โ†’ BetterDesk | โฌœ todo | `peer` โ†’ `peers` schema mapping | | 4.3 | BetterDesk SQLite โ†’ PostgreSQL | โฌœ todo | Full schema migration | | 4.4 | Node.js tables migration | โฌœ todo | address_books, groups, sysinfo | | 4.5 | Reverse: PG โ†’ SQLite | โฌœ todo | Downgrade/testing | | 4.6 | ALL-IN-ONE script integration | โฌœ todo | betterdesk.sh / .ps1 | --- ## ๐Ÿ—๏ธ Architecture โ€” Single Binary ``` betterdesk-server [--port 21116] [--relay-port 21117] [--api-port 21114] [--key ] Modes (all run by default in a single process): --mode=all โ† default, runs signal + relay + api in one process --mode=signal โ† only signal server (hbbs equivalent) --mode=relay โ† only relay server (hbbr equivalent) ``` ### Why single binary is possible and better: 1. **RustDesk has two binaries for historical reasons** โ€” hbbs and hbbr are separate processes because they were originally separate Rust crates. There is no technical requirement for this separation. 2. **Go goroutines** make it trivial to run multiple listeners in one process โ€” each listener (UDP, TCP, WS, relay) runs in its own goroutine. 3. **Shared state** โ€” signal server needs to tell clients which relay to use. In a single binary, this is just a reference to a struct; in two binaries, you need IPC or config. 4. **Simpler deployment** โ€” one binary, one systemd service, one Docker container. 5. **Optional separation** โ€” the `--mode` flag still allows running signal-only or relay-only for large-scale deployments with separate relay nodes. ### Folder Structure ``` betterdesk-server/ โ”œโ”€โ”€ main.go # Entry point, flag parsing, mode selection โ”œโ”€โ”€ go.mod โ”œโ”€โ”€ go.sum โ”œโ”€โ”€ proto/ # Generated Go protobuf code โ”‚ โ”œโ”€โ”€ rendezvous.pb.go โ”‚ โ””โ”€โ”€ message.pb.go โ”œโ”€โ”€ signal/ # Signal server (hbbs equivalent) โ”‚ โ”œโ”€โ”€ server.go # UDP/TCP/WS listeners, main loop โ”‚ โ”œโ”€โ”€ handler.go # Message handlers (RegisterPeer, PunchHole, etc.) โ”‚ โ”œโ”€โ”€ nat.go # NAT test (port 21115) โ”‚ โ””โ”€โ”€ online.go # OnlineRequest/Response handler โ”œโ”€โ”€ relay/ # Relay server (hbbr equivalent) โ”‚ โ”œโ”€โ”€ server.go # TCP/WS listeners โ”‚ โ”œโ”€โ”€ pair.go # UUID-based stream pairing โ”‚ โ”œโ”€โ”€ bandwidth.go # Rate limiting (per-conn + total) โ”‚ โ””โ”€โ”€ blacklist.go # IP blacklist/blocklist โ”œโ”€โ”€ api/ # HTTP REST API (port 21114) โ”‚ โ”œโ”€โ”€ server.go # Router setup โ”‚ โ”œโ”€โ”€ handlers.go # Endpoint handlers โ”‚ โ””โ”€โ”€ middleware.go # API key auth, CORS โ”œโ”€โ”€ crypto/ # Cryptographic utilities โ”‚ โ”œโ”€โ”€ keys.go # Ed25519 key generation, loading, signing โ”‚ โ””โ”€โ”€ addr_mangle.go # AddrMangle encode/decode โ”œโ”€โ”€ codec/ # Wire protocol โ”‚ โ”œโ”€โ”€ framing.go # TCP framing (2-byte BE length + protobuf) โ”‚ โ””โ”€โ”€ ws.go # WebSocket adapter โ”œโ”€โ”€ db/ # Database layer โ”‚ โ”œโ”€โ”€ sqlite.go # SQLite operations (modernc.org/sqlite or mattn/go-sqlite3) โ”‚ โ”œโ”€โ”€ models.go # Peer, Config structs โ”‚ โ””โ”€โ”€ migrations.go # Schema creation + migrations โ”œโ”€โ”€ peer/ # Peer management โ”‚ โ”œโ”€โ”€ map.go # Concurrent peer map (sync.RWMutex) โ”‚ โ”œโ”€โ”€ status.go # Online/offline/degraded/critical tracking โ”‚ โ””โ”€โ”€ ban.go # Device ban logic โ””โ”€โ”€ config/ # Configuration โ”œโ”€โ”€ config.go # CLI flags, env vars, defaults โ””โ”€โ”€ constants.go # Timeouts, ports, limits ``` --- ## ๐Ÿ“ก Protocol Specification (from .proto files) ### Package: `hbb` All messages use protobuf3 with package name `hbb`. ### rendezvous.proto โ€” Complete Message List ```protobuf // RendezvousMessage is the top-level envelope for ALL signal communication message RendezvousMessage { oneof union { RegisterPeer register_peer = 6; RegisterPeerResponse register_peer_response = 7; PunchHoleRequest punch_hole_request = 8; PunchHole punch_hole = 9; PunchHoleSent punch_hole_sent = 10; PunchHoleResponse punch_hole_response = 11; FetchLocalAddr fetch_local_addr = 12; LocalAddr local_addr = 13; ConfigUpdate configure_update = 14; RegisterPk register_pk = 15; RegisterPkResponse register_pk_response = 16; SoftwareUpdate software_update = 17; RequestRelay request_relay = 18; RelayResponse relay_response = 19; TestNatRequest test_nat_request = 20; TestNatResponse test_nat_response = 21; PeerDiscovery peer_discovery = 22; OnlineRequest online_request = 23; OnlineResponse online_response = 24; KeyExchange key_exchange = 25; HealthCheck hc = 26; } } ``` ### Key Messages Detail ```protobuf // Client registers its presence (heartbeat) โ€” sent via UDP every ~12s message RegisterPeer { string id = 1; int32 serial = 2; } message RegisterPeerResponse { bool request_pk = 2; } // Client registers its public key (first time or key rotation) message RegisterPk { string id = 1; bytes uuid = 2; // Device UUID (unique per installation) bytes pk = 3; // Ed25519 public key (32 bytes) string old_id = 4; // If set โ†’ ID change request (old_id โ†’ id) bool no_register_device = 5; } message RegisterPkResponse { enum Result { OK=0; UUID_MISMATCH=2; ID_EXISTS=3; TOO_FREQUENT=4; INVALID_ID_FORMAT=5; NOT_SUPPORT=6; SERVER_ERROR=7; } Result result = 1; int32 keep_alive = 2; // Suggested heartbeat interval in seconds } // Hole punching flow message PunchHoleRequest { string id = 1; // Target peer ID NatType nat_type = 2; string licence_key = 3; // Must match server key ConnType conn_type = 4; string token = 5; string version = 6; int32 udp_port = 7; bool force_relay = 8; int32 upnp_port = 9; bytes socket_addr_v6 = 10; } // Server sends to TARGET peer: "someone wants to connect, here's their addr" message PunchHole { bytes socket_addr = 1; // Requester's mangled address string relay_server = 2; // Fallback relay address NatType nat_type = 3; int32 udp_port = 4; bool force_relay = 5; int32 upnp_port = 6; bytes socket_addr_v6 = 7; ControlPermissions control_permissions = 8; } // Server sends to REQUESTER: "target's addr for direct punch" message PunchHoleSent { bytes socket_addr = 1; // Target's mangled address string id = 2; string relay_server = 3; NatType nat_type = 4; string version = 5; int32 upnp_port = 6; bytes socket_addr_v6 = 7; } // Target responds to requester with result message PunchHoleResponse { bytes socket_addr = 1; bytes pk = 2; // Target's public key enum Failure { ID_NOT_EXIST=0; OFFLINE=2; LICENSE_MISMATCH=3; LICENSE_OVERUSE=4; } Failure failure = 3; string relay_server = 4; oneof union { NatType nat_type = 5; bool is_local = 6; } string other_failure = 7; int32 feedback = 8; bool is_udp = 9; int32 upnp_port = 10; bytes socket_addr_v6 = 11; } // Relay request (when direct connection fails) message RequestRelay { string id = 1; // Target peer ID string uuid = 2; // Session UUID for pairing bytes socket_addr = 3; string relay_server = 4; bool secure = 5; string licence_key = 6; ConnType conn_type = 7; string token = 8; ControlPermissions control_permissions = 9; } message RelayResponse { bytes socket_addr = 1; string uuid = 2; string relay_server = 3; oneof union { string id = 4; bytes pk = 5; } string refuse_reason = 6; string version = 7; int32 feedback = 9; bytes socket_addr_v6 = 10; int32 upnp_port = 11; } // NAT type detection enum NatType { UNKNOWN_NAT=0; ASYMMETRIC=1; SYMMETRIC=2; } message TestNatRequest { int32 serial = 1; } message TestNatResponse { int32 port = 1; ConfigUpdate cu = 2; } // Online status check (TCP port 21115) message OnlineRequest { string id = 1; repeated string peers = 2; } message OnlineResponse { bytes states = 1; } // Bitmask: 2 bits per peer // Connection types enum ConnType { DEFAULT_CONN=0; FILE_TRANSFER=1; PORT_FORWARD=2; RDP=3; VIEW_CAMERA=4; TERMINAL=5; } // Health check message HealthCheck { string token = 1; } ``` ### message.proto โ€” Key Messages (for relay) The relay doesn't parse message.proto content โ€” it just forwards raw bytes. The only message from message.proto the signal server needs: ```protobuf message IdPk { string id = 1; bytes pk = 2; } ``` This is signed by the server's Ed25519 key and sent as the public key proof. --- ## ๐Ÿ”Œ Port Layout | Port | Protocol | Service | Go Listener | |------|----------|---------|-------------| | 21114 | HTTP | REST API | `net/http` or `gin`/`chi` | | 21115 | TCP | NAT test + OnlineRequest | `net.Listen("tcp", ":21115")` | | 21116 | UDP | Main signal (RegisterPeer, PunchHole) | `net.ListenPacket("udp", ":21116")` | | 21116 | TCP | TCP signal fallback | `net.Listen("tcp", ":21116")` | | 21117 | TCP | Relay (bidirectional stream) | `net.Listen("tcp", ":21117")` | | 21118 | WS | WebSocket signal (port = 21116 + 2) | `gorilla/websocket` or `nhooyr/websocket` | | 21119 | WS | WebSocket relay (port = 21117 + 2) | same library | **NOTE:** UDP and TCP can share port 21116 because they are different protocols. --- ## ๐Ÿ” Cryptography ### Ed25519 Key Management ``` Server startup: 1. Check for existing keypair file (id_ed25519, id_ed25519.pub) 2. If not found โ†’ generate new keypair: crypto/ed25519.GenerateKey() 3. Store private key as base64 (64 bytes = 32 seed + 32 public) 4. Public key = last 32 bytes of private key โ†’ base64 encode โ†’ this is the "key" string Signing IdPk: 1. Serialize IdPk{id, pk} to protobuf bytes 2. Sign with ed25519.Sign(privateKey, serialized) 3. Store signature โ€” sent to connecting peers as proof ``` ### AddrMangle Format ``` Encode(ip, port): IPv4: [4 bytes IP] + [2 bytes port big-endian] = 6 bytes total IPv6: [16 bytes IP] + [2 bytes port big-endian] = 18 bytes total Decode(bytes): len == 6 โ†’ IPv4: ip = bytes[0:4], port = binary.BigEndian.Uint16(bytes[4:6]) len == 18 โ†’ IPv6: ip = bytes[0:16], port = binary.BigEndian.Uint16(bytes[16:18]) Go implementation: func Encode(addr net.UDPAddr) []byte func Decode(b []byte) net.UDPAddr ``` --- ## ๐Ÿ“ฆ Wire Protocol โ€” Framing ### TCP Framing ``` Every TCP message is framed as: [2 bytes: payload length, big-endian] + [N bytes: protobuf-encoded RendezvousMessage] Go: // Write binary.BigEndian.PutUint16(buf, uint16(len(payload))) conn.Write(buf) conn.Write(payload) // Read io.ReadFull(conn, buf[:2]) length := binary.BigEndian.Uint16(buf) io.ReadFull(conn, payload[:length]) proto.Unmarshal(payload, &msg) ``` ### UDP (no framing) ``` One UDP datagram = one protobuf-encoded RendezvousMessage No length prefix needed โ€” datagram boundaries provide framing. ``` ### WebSocket ``` One WS binary message = one protobuf-encoded RendezvousMessage No additional framing โ€” WS message boundaries provide framing. Signal WS: port 21118 (signal_port + 2) Relay WS: port 21119 (relay_port + 2) ``` --- ## ๐Ÿ’พ Database Schema ```sql CREATE TABLE IF NOT EXISTS peer ( guid BLOB PRIMARY KEY NOT NULL, id VARCHAR(100) NOT NULL, uuid BLOB NOT NULL, pk BLOB NOT NULL, created_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), user BLOB, status TINYINT, -- 0=offline, 1=online note VARCHAR(300), info TEXT NOT NULL, -- JSON: {"ip":"..."} previous_ids TEXT DEFAULT '', id_changed_at TEXT DEFAULT '', is_deleted INTEGER DEFAULT 0, is_banned INTEGER DEFAULT 0, last_online TEXT ) WITHOUT ROWID; CREATE UNIQUE INDEX IF NOT EXISTS index_peer_id ON peer (id); CREATE INDEX IF NOT EXISTS index_peer_user ON peer (user); CREATE INDEX IF NOT EXISTS index_peer_created_at ON peer (created_at); CREATE INDEX IF NOT EXISTS index_peer_status ON peer (status); ``` --- ## ๐Ÿ”„ Signal Flow (step by step) ### 1. Device Registration ``` Client โ†’ Server (UDP 21116): RegisterPeer{id:"ABC123", serial:0} Server โ†’ Client (UDP 21116): RegisterPeerResponse{request_pk: true/false} If request_pk == true: Client โ†’ Server (UDP 21116): RegisterPk{id:"ABC123", uuid:, pk:<32 bytes>} Server โ†’ Client (UDP 21116): RegisterPkResponse{result:OK, keep_alive:12} Client repeats RegisterPeer every ~12 seconds (heartbeat). If server doesn't receive heartbeat for REG_TIMEOUT (15s), peer is stale. ``` ### 2. Connection Request (Hole Punching) ``` Initiator โ†’ Server (UDP 21116): PunchHoleRequest{id:"TARGET_ID", nat_type:..., licence_key:...} Server checks: - Is TARGET_ID registered and online? If not โ†’ PunchHoleResponse{failure:OFFLINE} - Is TARGET_ID banned? If yes โ†’ PunchHoleResponse{failure:OFFLINE} - Does licence_key match? If not โ†’ PunchHoleResponse{failure:LICENSE_MISMATCH} Server โ†’ Target (UDP 21116): PunchHole{ socket_addr: AddrMangle(initiator_addr), relay_server: "relay.example.com:21117", nat_type: initiator_nat_type } Server โ†’ Initiator (UDP 21116): PunchHoleSent{ socket_addr: AddrMangle(target_addr), id: "TARGET_ID", relay_server: "relay.example.com:21117", nat_type: target_nat_type } Both sides attempt direct UDP connection. If it fails โ†’ relay. ``` ### 3. Relay Flow ``` Side A โ†’ Relay (TCP 21117): [framed] RequestRelay{uuid:"session-uuid-123", id:"TARGET_ID"} Relay stores connection in pending map with uuid as key, waits 30s Side B โ†’ Relay (TCP 21117): [framed] RequestRelay{uuid:"session-uuid-123", id:"..."} Relay finds matching uuid โ†’ pair established Relay enters bidirectional copy: Side A bytes โ†’ Side B Side B bytes โ†’ Side A Relay does NOT parse message.proto content โ€” it's an opaque byte pipe. Connection closes when either side disconnects or idle timeout (30s). ``` ### 4. NAT Test ``` Client โ†’ Server (TCP 21115): [framed] TestNatRequest{serial:0} Server โ†’ Client (TCP 21115): [framed] TestNatResponse{port: } Client compares observed port with local port to determine NAT type. ``` ### 5. Online Status Check ``` Client โ†’ Server (TCP 21115): [framed] OnlineRequest{id:"me", peers:["A","B","C"]} Server โ†’ Client (TCP 21115): [framed] OnlineResponse{states: } states is a bitmask: 2 bits per peer 00 = offline 01 = online ``` --- ## โš™๏ธ Configuration (env vars + CLI flags) | Env Var | CLI Flag | Default | Description | |---------|----------|---------|-------------| | `PORT` | `--port` | 21116 | Signal server port | | `RELAY_PORT` | `--relay-port` | 21117 | Relay server port | | `API_PORT` | `--api-port` | 21114 | HTTP API port | | `KEY` | `--key` | (auto-gen) | Ed25519 private key (base64) | | `DB_URL` | `--db` | `./db_v2.sqlite3` | SQLite database path | | `MODE` | `--mode` | `all` | `all`, `signal`, `relay` | | `REG_TIMEOUT` | - | 15000 | Registration timeout (ms) | | `HEARTBEAT_INTERVAL_SECS` | - | 3 | Heartbeat check interval | | `PEER_TIMEOUT_SECS` | - | 15 | Mark offline after N seconds | | `ALWAYS_USE_RELAY` | - | `N` | Force relay for all connections | | `TOTAL_BANDWIDTH` | - | 1024 | Total bandwidth limit (Mb/s) | | `SINGLE_BANDWIDTH` | - | 16 | Per-connection bandwidth (Mb/s) | | `LIMIT_SPEED` | - | 4 | Blacklisted speed limit (Mb/s) | | `DOWNGRADE_THRESHOLD` | - | 0.66 | Bandwidth downgrade ratio | | `RELAY_SERVERS` | `--relay-servers` | (self) | Comma-separated relay servers | | `RENDEZVOUS_SERVERS` | `--rendezvous-servers` | (self) | Comma-separated signal servers | | `MASK` | `--mask` | - | CIDR for LAN peer detection (e.g. 192.168.0.0/16); empty defaults to IPv4 /24 | --- ## ๐Ÿ“š Go Dependencies ```go // go.mod module github.com/unitronix/betterdesk-server go 1.22 require ( google.golang.org/protobuf v1.34.0 // Protobuf runtime github.com/gorilla/websocket v1.5.3 // WebSocket support modernc.org/sqlite v1.29.0 // Pure Go SQLite (no CGO) // OR github.com/mattn/go-sqlite3 // CGO SQLite (faster) github.com/google/uuid v1.6.0 // UUID generation ) ``` ### Why `modernc.org/sqlite` (pure Go)? - No CGO required โ†’ trivial cross-compilation (`GOOS=linux GOARCH=arm64 go build`) - Single static binary, no `.so`/`.dll` dependencies - Performance is ~90% of CGO version, sufficient for our use case --- ## ๐Ÿงช Testing Strategy ### Unit Tests (per package) ``` crypto/ โ†’ TestAddrMangleIPv4, TestAddrMangleIPv6, TestKeyGeneration, TestSign codec/ โ†’ TestFrameWrite, TestFrameRead, TestRoundTrip signal/ โ†’ TestRegisterPeer, TestPunchHoleFlow, TestNatTest relay/ โ†’ TestPairing, TestBandwidthLimit, TestBlacklist db/ โ†’ TestInsertPeer, TestSetOnline, TestBanCheck, TestIDChange api/ โ†’ TestHealthEndpoint, TestPeersEndpoint, TestAPIKeyAuth ``` ### Integration Tests ``` 1. Start betterdesk-server in test mode 2. Use Go test client that speaks the protobuf protocol 3. Test full flows: register โ†’ punch hole โ†’ relay โ†’ disconnect 4. Test with real RustDesk client binary (manual/CI) ``` --- ## ๐Ÿš€ Build & Deploy ### Build Commands ```bash # Linux amd64 GOOS=linux GOARCH=amd64 go build -o betterdesk-server-linux-amd64 . # Linux arm64 GOOS=linux GOARCH=arm64 go build -o betterdesk-server-linux-arm64 . # Windows amd64 GOOS=windows GOARCH=amd64 go build -o betterdesk-server-windows-amd64.exe . # With version info go build -ldflags "-X main.Version=1.0.0 -X main.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o betterdesk-server . ``` ### Systemd Service (replaces both hbbs + hbbr services) ```ini [Unit] Description=BetterDesk Server (Signal + Relay + API) After=network.target [Service] Type=simple ExecStart=/opt/betterdesk/betterdesk-server --port 21116 --relay-port 21117 --api-port 21114 WorkingDirectory=/opt/betterdesk Restart=always RestartSec=5 LimitNOFILE=65536 [Install] WantedBy=multi-user.target ``` --- ## ๐Ÿ“‹ Implementation Order (recommended) ### Phase 1: Foundation (Tasks 0.1โ€“0.5) 1. **Project skeleton** โ€” `go mod init`, folder structure, `main.go` 2. **Protobuf compilation** โ€” generate Go code from `.proto` files 3. **AddrMangle** โ€” encode/decode with unit tests 4. **Framing codec** โ€” TCP read/write with length prefix 5. **Ed25519 keys** โ€” generate, load, save, sign `IdPk` ### Phase 2: Signal Server (Tasks 0.6โ€“0.9) 6. **UDP listener** โ€” handle RegisterPeer, RegisterPk, PunchHoleRequest 7. **Peer map** โ€” concurrent in-memory map with heartbeat tracking 8. **SQLite database** โ€” peer table, CRUD, status tracking 9. **TCP signal** โ€” same handlers, framed protocol 10. **NAT test** โ€” port 21115, TestNatRequest/Response + OnlineRequest 11. **WebSocket signal** โ€” port 21118, same handlers over WS ### Phase 3: Relay Server (Tasks 0.10โ€“0.11) 12. **TCP relay** โ€” UUID pairing, bidirectional copy, 30s timeout 13. **WS relay** โ€” port 21119, same pairing logic 14. **Bandwidth limiting** โ€” token bucket per-connection + global 15. **Blacklist/blocklist** โ€” IP-based, file-loaded ### Phase 4: API & Features (Tasks 0.12โ€“0.18) 16. **HTTP API** โ€” all existing endpoints from BetterDesk 17. **Ban system** โ€” integrated with signal + relay 18. **ID change** โ€” with rate limiting and history 19. **Integration with web-nodejs** โ€” replace Rust hbbs, keep Node.js console ### Phase 5: Production (Tasks 0.19โ€“0.20) 20. **Full integration testing** with real RustDesk clients 21. **Cross-compilation** and release pipeline 22. **Update ALL-IN-ONE scripts** (`betterdesk.sh`, `betterdesk.ps1`) for Go binary --- ## โš ๏ธ Critical Implementation Notes ### 1. DO NOT copy Rust code We implement from the protocol spec only. Reference the `.proto` files and the port/flow documentation above, never the `.rs` source files. ### 2. RegisterPeer is the heartbeat The client sends `RegisterPeer` via UDP every ~12 seconds. If we don't receive it for `REG_TIMEOUT` (15s default), the peer is considered offline. ### 3. PunchHole is time-critical The server must relay PunchHole messages with minimal latency. Both sides attempt simultaneous UDP hole-punching. Delays > 500ms can cause failure. ### 4. Relay pairing uses UUID Both sides of a relay connection send `RequestRelay{uuid:"same-uuid"}`. The relay server pairs them by UUID. First connection waits up to 30 seconds for the second. ### 5. WebSocket is optional but important Web clients (browser-based RustDesk) use WebSocket exclusively. Desktop clients prefer UDP/TCP but fall back to WS. ### 6. Key compatibility The server's Ed25519 keypair must be compatible with existing RustDesk clients. The public key is base64-encoded and distributed to clients via the `key=` parameter. Clients verify the server's signature on `IdPk` messages. ### 7. Database compatibility Use the same SQLite schema as the current Rust server (`db_v2.sqlite3`) so the Node.js web console (`web-nodejs/`) works without changes. --- ## ๐Ÿ”— Integration with Existing BetterDesk Components The Go server replaces ONLY the Rust binaries (`hbbs` + `hbbr`). Everything else stays: | Component | Status | Notes | |-----------|--------|-------| | `web-nodejs/` | โœ… Unchanged | Reads `db_v2.sqlite3` + calls HTTP API on 21114 | | `betterdesk.sh` | ๐Ÿ”ง Update needed | Download/install Go binary instead of Rust binaries | | `betterdesk.ps1` | ๐Ÿ”ง Update needed | Same | | `betterdesk-docker.sh` | ๐Ÿ”ง Update needed | Update Dockerfile for single binary | | `Dockerfile.hbbs` | ๐Ÿ”„ Replace | Single `Dockerfile.server` for Go binary | | `Dockerfile.hbbr` | ๐Ÿ”„ Remove | Not needed โ€” single binary | | `docker-compose.yml` | ๐Ÿ”ง Update needed | Single `server` service instead of `hbbs` + `hbbr` | --- *Last updated: 2026-02-28* *Author: GitHub Copilot* --- ## ๐Ÿ” Security Audit Findings (2026-02-28) ### HIGH | ID | Issue | File | Fix | Status | |----|-------|------|-----|--------| | H1 | No validation of `new_id` in change-id API | `api/server.go:297-324` | `peerIDRegexp` check | โœ… Fixed | | H2 | `FindByIP` fallback returns first peer behind NAT | `peer/map.go` / `signal/handler.go` | Outbound auth: exact `FindByAddr` / TCP session / token / panel proxy / `udp_port` hint / safe single-IP fallback; multi-peer โ†’ `initiator_ambiguous_same_nat` unless opt-in `ALLOW_SHARED_NAT_INITIATOR` (synthetic `shared-nat-initiator`, #399). RelayResponse uses pending UUID+initiator store โ€” no multi-peer `FindByIP` delivery. | โœ… Fixed (auth + #399 forward) | | H3 | No rate-limit on `/api/auth/login/2fa` | `api/auth_handlers.go:127-168` | `loginLimiter.Allow(clientIP)` + audit log | โœ… Fixed | | H4 | Partial 2FA token has 24h TTL | `api/auth_handlers.go:104-111` | `GenerateWithTTL()` 5min | โœ… Fixed | ### MEDIUM | ID | Issue | File | Fix | Status | |----|-------|------|-----|--------| | M1 | SQL LIKE without escaping `%`/`_` | `db/sqlite.go:329-338` | Escape before LIKE | โœ… Fixed | | M2 | No TTL/max-size for `tcpPunchConns` | `signal/server.go:83` | 2min TTL + 10K cap + cleanup goroutine | โœ… Fixed | | M3 | WebSocket accept without origin check | `signal/ws.go:49`, `relay/ws.go:46` | `WS_ALLOWED_ORIGINS` env var + `OriginPatterns` | โœ… Fixed | | M4 | No rate-limit on TCP signal connections | `signal/server.go:224` | `limiter.Allow(host)` in `serveTCP()` | โœ… Fixed | | M5 | Heartbeat debounce 60s โ€” stale DB status | `signal/handler.go:151-154` | Acceptable trade-off | โš ๏ธ Won't fix | | M6 | No validation of config key names | `api/server.go:348-370` | `configKeyRegexp` validation | โœ… Fixed | | M7 | `io.Copy` relay without idle timeout | `relay/server.go:193-200` | `idleTimeoutConn` wrapper, 30s idle deadline | โœ… Fixed | | M8 | No `ConfigUpdate` in `TestNatResponse` | `signal/handler.go:830-841` | `Cu` field with relay/rendezvous servers | โœ… Fixed | ### Encryption Assessment โ€” CORRECT | Aspect | Status | |--------|--------| | Ed25519 key generation + persistence | โœ… | | Ed25519 โ†’ Curve25519 conversion | โœ… Matches libsodium | | NaCl KeyExchange (signed 96-byte payload) | โœ… | | NaCl secretbox (pre-increment nonce LE u64) | โœ… Matches RustDesk | | Backward compatibility (non-KeyExchange clients) | โœ… | | TLS for HTTP API | โœ… Optional via `--tls-cert`/`--tls-key` | | TLS for signal/relay TCP | โœ… Dual-mode via `DualModeListener` (Phase 3) | | WSS for signal/relay WS | โœ… `ListenAndServeTLS` with shared `LoadTLSConfig` (Phase 3) | ### Database Interface The `Database` interface in `db/database.go` (25+ methods) is already designed for multiple backends. PostgreSQL implementation needs `db/postgres.go` only. Shared SQLite access between Go server and Node.js console works correctly โ€” they use different tables (Go: peers/users/api_keys, Node.js: address_books/device_groups/sysinfo).