diff --git a/Dockerfile b/Dockerfile index 029764f..1da6b1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,8 @@ RUN apk add --no-cache ca-certificates tzdata && \ chown -R olcrtc:olcrtc /var/lib/olcrtc COPY --from=build /out/olcrtc /usr/local/bin/olcrtc -COPY internal/names/data/names internal/names/data/surnames /usr/share/olcrtc/ -COPY docker/olcrtc-entrypoint.sh /usr/local/bin/olcrtc-entrypoint -COPY docker/olcrtc-healthcheck.sh /usr/local/bin/olcrtc-healthcheck +COPY script/docker/olcrtc-entrypoint.sh /usr/local/bin/olcrtc-entrypoint +COPY script/docker/olcrtc-healthcheck.sh /usr/local/bin/olcrtc-healthcheck RUN chmod 0755 /usr/local/bin/olcrtc /usr/local/bin/olcrtc-entrypoint /usr/local/bin/olcrtc-healthcheck diff --git a/cmd/olcrtc/main.go b/cmd/olcrtc/main.go index c27bb61..775d7c5 100644 --- a/cmd/olcrtc/main.go +++ b/cmd/olcrtc/main.go @@ -1,8 +1,11 @@ +// Package main provides the olcrtc CLI entrypoint. package main import ( "context" + "errors" "flag" + "fmt" "log" "os" "os/signal" @@ -16,68 +19,51 @@ import ( "github.com/openlibrecommunity/olcrtc/internal/server" ) +type config struct { + mode string + roomID string + provider string + socksPort int + socksHost string + keyHex string + debug bool + dataDir string + duo bool + dnsServer string + socksProxyAddr string + socksProxyPort int +} + +var ( + errUnsupportedProvider = errors.New("only telemost provider supported") + errRoomIDRequired = errors.New("room ID required") + errModeRequired = errors.New("specify -mode srv or -mode cnc") +) + func main() { - var ( - mode string - roomID string - provider string - socksPort int - keyHex string - debug bool - dataDir string - duo bool - dnsServer string - socksProxyAddr string - socksProxyPort int - ) + if err := run(); err != nil { + log.Print(err) + os.Exit(1) + } +} - flag.StringVar(&mode, "mode", "", "Mode: srv or cnc") - flag.StringVar(&roomID, "id", "", "Telemost room ID") - flag.StringVar(&provider, "provider", "telemost", "Provider (telemost only)") - flag.IntVar(&socksPort, "socks-port", 1080, "SOCKS5 port (client only)") - flag.StringVar(&keyHex, "key", "", "Shared encryption key (hex)") - flag.BoolVar(&debug, "debug", false, "Enable verbose logging") - flag.StringVar(&dataDir, "data", "data", "Path to data directory") - flag.BoolVar(&duo, "duo", false, "Use dual channels for 2x throughput") - flag.StringVar(&dnsServer, "dns", "1.1.1.1:53", "DNS server (default: Cloudflare 1.1.1.1)") - flag.StringVar(&socksProxyAddr, "socks-proxy", "", "SOCKS5 proxy address (server only)") - flag.IntVar(&socksProxyPort, "socks-proxy-port", 1080, "SOCKS5 proxy port (server only)") - flag.Parse() +func run() error { + cfg := parseFlags() + configureLogging(cfg.debug) - if debug { - log.SetFlags(log.Ltime | log.Lshortfile) - logger.SetVerbose(true) - } else { - log.SetFlags(log.Ltime) + if err := validateConfig(cfg); err != nil { + return err } - if provider != "telemost" { - log.Fatal("Only telemost provider supported") + dataDir, err := resolveDataDir(cfg.dataDir) + if err != nil { + return err } - if roomID == "" { - log.Fatal("Room ID required") + if err := loadNames(dataDir); err != nil { + return err } - if mode != "srv" && mode != "cnc" { - log.Fatal("Specify -mode srv or -mode cnc") - } - - if !filepath.IsAbs(dataDir) { - exePath, err := os.Executable() - if err == nil { - exeDir := filepath.Dir(exePath) - dataDir = filepath.Join(exeDir, dataDir) - } - } - - namesPath := filepath.Join(dataDir, "names") - surnamesPath := filepath.Join(dataDir, "surnames") - - names.LoadNameFiles(namesPath, surnamesPath) - - roomURL := "https://telemost.yandex.ru/j/" + roomID - ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -85,36 +71,126 @@ func main() { signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) errCh := make(chan error, 1) - - go func() { - switch mode { - case "srv": - errCh <- server.Run(ctx, roomURL, keyHex, duo, dnsServer, socksProxyAddr, socksProxyPort) - case "cnc": - errCh <- client.Run(ctx, roomURL, keyHex, socksPort, duo, "", "") - } - }() + go runMode(ctx, cfg, errCh) select { case <-sigCh: log.Println("Shutting down gracefully...") cancel() - - done := make(chan struct{}) - go func() { - <-errCh - close(done) - }() - - select { - case <-done: - log.Println("Shutdown complete") - case <-time.After(5 * time.Second): - log.Println("Shutdown timeout, forcing exit") - } + return waitForShutdown(errCh) case err := <-errCh: - if err != nil { - log.Fatal(err) - } + return err + } +} + +func parseFlags() config { + cfg := config{} + + flag.StringVar(&cfg.mode, "mode", "", "Mode: srv or cnc") + flag.StringVar(&cfg.roomID, "id", "", "Telemost room ID") + flag.StringVar(&cfg.provider, "provider", "telemost", "Provider (telemost only)") + flag.IntVar(&cfg.socksPort, "socks-port", 1080, "SOCKS5 port (client only)") + flag.StringVar(&cfg.socksHost, "socks-host", "127.0.0.1", "SOCKS5 listen host (client only)") + flag.StringVar(&cfg.keyHex, "key", "", "Shared encryption key (hex)") + flag.BoolVar(&cfg.debug, "debug", false, "Enable verbose logging") + flag.StringVar(&cfg.dataDir, "data", "data", "Path to data directory") + flag.BoolVar(&cfg.duo, "duo", false, "Use dual channels for 2x throughput") + flag.StringVar(&cfg.dnsServer, "dns", "1.1.1.1:53", "DNS server (default: Cloudflare 1.1.1.1)") + flag.StringVar(&cfg.socksProxyAddr, "socks-proxy", "", "SOCKS5 proxy address (server only)") + flag.IntVar(&cfg.socksProxyPort, "socks-proxy-port", 1080, "SOCKS5 proxy port (server only)") + flag.Parse() + + return cfg +} + +func configureLogging(debug bool) { + if debug { + log.SetFlags(log.Ltime | log.Lshortfile) + logger.SetVerbose(true) + return + } + + log.SetFlags(log.Ltime) +} + +func validateConfig(cfg config) error { + switch { + case cfg.provider != "telemost": + return errUnsupportedProvider + case cfg.roomID == "": + return errRoomIDRequired + case cfg.mode != "srv" && cfg.mode != "cnc": + return errModeRequired + default: + return nil + } +} + +func resolveDataDir(dataDir string) (string, error) { + if filepath.IsAbs(dataDir) { + return dataDir, nil + } + + exePath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve executable path: %w", err) + } + + return filepath.Join(filepath.Dir(exePath), dataDir), nil +} + +func loadNames(dataDir string) error { + namesPath := filepath.Join(dataDir, "names") + surnamesPath := filepath.Join(dataDir, "surnames") + if err := names.LoadNameFiles(namesPath, surnamesPath); err != nil { + return fmt.Errorf("load embedded names override: %w", err) + } + + return nil +} + +func runMode(ctx context.Context, cfg config, errCh chan<- error) { + roomURL := "https://telemost.yandex.ru/j/" + cfg.roomID + + switch cfg.mode { + case "srv": + errCh <- server.Run( + ctx, + roomURL, + cfg.keyHex, + cfg.duo, + cfg.dnsServer, + cfg.socksProxyAddr, + cfg.socksProxyPort, + ) + case "cnc": + errCh <- client.Run( + ctx, + roomURL, + cfg.keyHex, + cfg.socksPort, + cfg.duo, + cfg.socksHost, + "", + "", + ) + } +} + +func waitForShutdown(errCh <-chan error) error { + done := make(chan error, 1) + go func() { + done <- <-errCh + }() + + select { + case err := <-done: + if err == nil { + log.Println("Shutdown complete") + } + return err + case <-time.After(5 * time.Second): + log.Println("Shutdown timeout, forcing exit") + return nil } } diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index eb87fd4..0000000 --- a/docker/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# OlcRTC server Docker image - -This image runs `olcrtc` in server mode. The server does not expose an inbound -TCP port; it keeps outbound WebSocket/WebRTC connections to Telemost and relays -client traffic through the room. - -## Build - -```bash -docker build -t olcrtc/server:local . -``` - -For Podman: - -```bash -podman build -t olcrtc/server:local . -``` - -## Run - -```bash -docker run -d \ - --name olcrtc-server \ - --restart unless-stopped \ - -e OLCRTC_ROOM_ID="your-room-id" \ - -e OLCRTC_KEY="64-hex-character-shared-key" \ - -v olcrtc-state:/var/lib/olcrtc \ - olcrtc/server:local -``` - -If `OLCRTC_KEY` is omitted, the entrypoint generates a 32-byte key, stores it -in `/var/lib/olcrtc/key.hex`, and prints it once to the logs: - -```bash -docker logs olcrtc-server -``` - -Use the same key on clients. - -## Compose - -```bash -export OLCRTC_ROOM_ID="your-room-id" -export OLCRTC_KEY="64-hex-character-shared-key" -docker compose -f docker-compose.server.yml up -d --build -``` - -Optional environment variables: - -- `OLCRTC_DNS`: DNS resolver for outbound TCP dials, default `1.1.1.1:53` -- `OLCRTC_DUO`: set to `true` for two parallel WebRTC channels -- `OLCRTC_DEBUG`: set to `true` for verbose logs -- `OLCRTC_KEY_FILE`: persistent key path, default `/var/lib/olcrtc/key.hex` - -## Operational notes - -- The container runs as a non-root `olcrtc` user. -- The runtime image includes CA certificates for Telemost HTTPS/WSS. -- The healthcheck verifies that the container's PID 1 is the `olcrtc` process. -- No `EXPOSE` is declared because server mode does not accept inbound traffic. diff --git a/internal/client/client.go b/internal/client/client.go index a50340d..f89ccc1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,3 +1,4 @@ +// Package client implements the local SOCKS5 client side of the olcrtc tunnel. package client import ( @@ -6,10 +7,12 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log" "net" + "strconv" "sync" "sync/atomic" "time" @@ -22,6 +25,13 @@ import ( "github.com/pion/webrtc/v4" ) +var ( + errInvalidKeyLength = errors.New("key must be 32 bytes") + errInvalidKeyStringLength = errors.New("key string length must be 32") + errNoConnectedPeers = errors.New("no connected peers available") +) + +// Client manages the client-side mux and SOCKS5 listener. type Client struct { peers []*telemost.Peer cipher *crypto.Cipher @@ -31,143 +41,70 @@ type Client struct { wg sync.WaitGroup } -func Run(ctx context.Context, roomURL, keyHex string, socksPort int, duo bool, socksUser, socksPass string) error { - return RunWithReady(ctx, roomURL, keyHex, socksPort, duo, socksUser, socksPass, nil) +const defaultSOCKSListenHost = "127.0.0.1" + +// Run starts the client and listens for SOCKS5 traffic. +func Run( + ctx context.Context, + roomURL, + keyHex string, + socksPort int, + duo bool, + socksHost, + socksUser, + socksPass string, +) error { + return RunWithReady(ctx, roomURL, keyHex, socksPort, duo, socksHost, socksUser, socksPass, nil) } -func RunWithReady(ctx context.Context, roomURL, keyHex string, socksPort int, duo bool, socksUser, socksPass string, onReady func()) error { +// RunWithReady starts the client and invokes onReady once the local SOCKS5 listener is accepting connections. +func RunWithReady( + ctx context.Context, + roomURL, + keyHex string, + socksPort int, + duo bool, + socksHost, + socksUser, + socksPass string, + onReady func(), +) error { runCtx, cancel := context.WithCancel(ctx) defer cancel() - var key []byte - var err error - - if keyHex == "" { - key = make([]byte, 32) - if _, err := rand.Read(key); err != nil { - return err - } - log.Printf("Generated key: %x", key) - } else { - key, err = hex.DecodeString(keyHex) - if err != nil { - return err - } - if len(key) != 32 { - return fmt.Errorf("key must be 32 bytes, got %d", len(key)) - } - } - - keyStr := string(key) - if len(keyStr) != 32 { - return fmt.Errorf("key string length must be 32, got %d", len(keyStr)) - } - - cipher, err := crypto.NewCipher(keyStr) + key, err := decodeKey(keyHex) if err != nil { return err } - clientID := uint32(time.Now().UnixNano() & 0xFFFFFFFF) + keyStr := string(key) + if len(keyStr) != 32 { + return fmt.Errorf("%w: got %d", errInvalidKeyStringLength, len(keyStr)) + } + + cipher, err := crypto.NewCipher(keyStr) + if err != nil { + return fmt.Errorf("create cipher: %w", err) + } c := &Client{ cipher: cipher, - clientID: clientID, - peers: make([]*telemost.Peer, 0), + clientID: uint32(time.Now().UnixNano() & 0xFFFFFFFF), + peers: make([]*telemost.Peer, 0, peerCount(duo)), } - peerCount := 1 - if duo { - peerCount = 2 - log.Println("Duo mode: using 2 parallel channels") - } + c.mux = mux.New(c.clientID, c.sendFrame) - c.mux = mux.New(c.clientID, func(frame []byte) error { - for { - canSend := true - for _, peer := range c.peers { - if !peer.CanSend() { - canSend = false - break - } - } - if canSend { - break - } - time.Sleep(10 * time.Millisecond) - } - - encrypted, err := c.cipher.Encrypt(frame) - if err != nil { + for peerID := range peerCount(duo) { + if err := c.addPeer(runCtx, roomURL, peerID, cancel); err != nil { return err } - - idx := c.peerIdx.Add(1) % uint32(len(c.peers)) - return c.peers[idx].Send(encrypted) - }) - - for i := 0; i < peerCount; i++ { - peerID := i - peer, err := telemost.NewPeer(roomURL, names.Generate(), c.onData) - if err != nil { - return err - } - peer.SetEndedCallback(func(reason string) { - log.Printf("Client peer %d reported conference end: %s", peerID, reason) - cancel() - }) - c.peers = append(c.peers, peer) - - peer.SetReconnectCallback(func(dc *webrtc.DataChannel) { - if dc == nil { - log.Printf("Client peer %d channel closed - resetting multiplexer state", peerID) - } else { - log.Printf("Client peer %d reconnected - resetting multiplexer state", peerID) - } - - c.mux.UpdateSendFunc(func(frame []byte) error { - encrypted, err := c.cipher.Encrypt(frame) - if err != nil { - return err - } - idx := c.peerIdx.Add(1) % uint32(len(c.peers)) - return c.peers[idx].Send(encrypted) - }) - - c.mux.Reset() - - log.Println("Client multiplexer reset complete") - }) - - log.Printf("Connecting peer %d to Telemost...", peerID) - if err := peer.Connect(runCtx); err != nil { - return err - } - log.Printf("Peer %d connected", peerID) - - c.wg.Add(1) - go func() { - defer c.wg.Done() - peer.WatchConnection(runCtx) - }() } time.Sleep(100 * time.Millisecond) + c.sendResetSignal() - resetFrame := mux.BuildControlFrame(c.clientID, mux.ControlResetClient) - encrypted, err := cipher.Encrypt(resetFrame) - if err != nil { - log.Printf("Failed to encrypt reset signal: %v", err) - } else { - for _, peer := range c.peers { - if err := peer.Send(encrypted); err != nil { - log.Printf("Failed to send reset signal to server: %v", err) - } - } - log.Printf("Sent reset signal to server (clientID=%d)", c.clientID) - } - - err = c.runSOCKS5(runCtx, socksPort, socksUser, socksPass, onReady) + err = c.runSOCKS5(runCtx, socksHost, socksPort, socksUser, socksPass, onReady) log.Println("Waiting for client goroutines...") c.wg.Wait() @@ -176,6 +113,154 @@ func RunWithReady(ctx context.Context, roomURL, keyHex string, socksPort int, du return err } +func peerCount(duo bool) int { + if duo { + log.Println("Duo mode: using 2 parallel channels") + return 2 + } + + return 1 +} + +func decodeKey(keyHex string) ([]byte, error) { + if keyHex == "" { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate random key: %w", err) + } + + log.Printf("Generated key: %x", key) + return key, nil + } + + key, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("decode hex key: %w", err) + } + + if len(key) != 32 { + return nil, fmt.Errorf("%w: got %d", errInvalidKeyLength, len(key)) + } + + return key, nil +} + +func (c *Client) sendFrame(frame []byte) error { + waitUntilPeersCanSend(c.peers) + + encrypted, err := c.cipher.Encrypt(frame) + if err != nil { + return fmt.Errorf("encrypt outgoing frame: %w", err) + } + + peer, err := c.nextPeer() + if err != nil { + return err + } + + if err := peer.Send(encrypted); err != nil { + return fmt.Errorf("send frame via peer: %w", err) + } + + return nil +} + +func waitUntilPeersCanSend(peers []*telemost.Peer) { + for { + canSend := true + for _, peer := range peers { + if !peer.CanSend() { + canSend = false + break + } + } + + if canSend { + return + } + + time.Sleep(10 * time.Millisecond) + } +} + +func (c *Client) nextPeer() (*telemost.Peer, error) { + switch len(c.peers) { + case 0: + return nil, errNoConnectedPeers + case 1: + return c.peers[0], nil + default: + return c.peers[int(c.peerIdx.Add(1)%2)], nil + } +} + +func (c *Client) addPeer( + runCtx context.Context, + roomURL string, + peerID int, + cancel context.CancelFunc, +) error { + peer, err := telemost.NewPeer(roomURL, names.Generate(), c.onData) + if err != nil { + return fmt.Errorf("create peer %d: %w", peerID, err) + } + + peer.SetEndedCallback(func(reason string) { + log.Printf("Client peer %d reported conference end: %s", peerID, reason) + cancel() + }) + + peer.SetReconnectCallback(func(dc *webrtc.DataChannel) { + c.onReconnect(peerID, dc) + }) + + c.peers = append(c.peers, peer) + + log.Printf("Connecting peer %d to Telemost...", peerID) + if err := peer.Connect(runCtx); err != nil { + return fmt.Errorf("connect peer %d: %w", peerID, err) + } + log.Printf("Peer %d connected", peerID) + + c.wg.Add(1) + go func() { + defer c.wg.Done() + peer.WatchConnection(runCtx) + }() + + return nil +} + +func (c *Client) onReconnect(peerID int, dc *webrtc.DataChannel) { + if dc == nil { + log.Printf("Client peer %d channel closed - resetting multiplexer state", peerID) + } else { + log.Printf("Client peer %d reconnected - resetting multiplexer state", peerID) + } + + c.mux.UpdateSendFunc(c.sendFrame) + c.mux.Reset() + + log.Println("Client multiplexer reset complete") +} + +func (c *Client) sendResetSignal() { + resetFrame := mux.BuildControlFrame(c.clientID, mux.ControlResetClient) + encrypted, err := c.cipher.Encrypt(resetFrame) + if err != nil { + log.Printf("Failed to encrypt reset signal: %v", err) + return + } + + for _, peer := range c.peers { + if err := peer.Send(encrypted); err != nil { + log.Printf("Failed to send reset signal to server: %v", err) + } + } + + log.Printf("Sent reset signal to server (clientID=%d)", c.clientID) +} + func (c *Client) onData(data []byte) { plaintext, err := c.cipher.Decrypt(data) if err != nil { @@ -186,13 +271,26 @@ func (c *Client) onData(data []byte) { c.mux.HandleFrame(plaintext) } -func (c *Client) runSOCKS5(ctx context.Context, port int, username, password string, onReady func()) error { - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - return err +func (c *Client) runSOCKS5( + ctx context.Context, + host string, + port int, + username, + password string, + onReady func(), +) error { + if host == "" { + host = defaultSOCKSListenHost } - log.Printf("SOCKS5 proxy listening on 127.0.0.1:%d (auth=%v)", port, username != "") + listenAddr := net.JoinHostPort(host, strconv.Itoa(port)) + var lc net.ListenConfig + listener, err := lc.Listen(ctx, "tcp", listenAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", listenAddr, err) + } + + log.Printf("SOCKS5 proxy listening on %s (auth=%v)", listenAddr, username != "") if onReady != nil { onReady() } @@ -200,7 +298,9 @@ func (c *Client) runSOCKS5(ctx context.Context, port int, username, password str go func() { <-ctx.Done() log.Println("Closing SOCKS5 listener...") - listener.Close() + if err := listener.Close(); err != nil { + logger.Debug("SOCKS5 listener close error: %v", err) + } }() for { @@ -209,11 +309,7 @@ func (c *Client) runSOCKS5(ctx context.Context, port int, username, password str select { case <-ctx.Done(): log.Println("SOCKS5 listener closed") - - for _, peer := range c.peers { - peer.Close() - } - + c.closePeers() return nil default: log.Printf("Accept error: %v", err) @@ -225,16 +321,24 @@ func (c *Client) runSOCKS5(ctx context.Context, port int, username, password str } } +func (c *Client) closePeers() { + for _, peer := range c.peers { + if err := peer.Close(); err != nil { + logger.Debug("Peer close error: %v", err) + } + } +} + +//nolint:cyclop // SOCKS5 parsing is inherently stateful and mirrors the protocol handshake. func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + logger.Debug("SOCKS5 connection close error: %v", err) + } + }() buf := make([]byte, 513) - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return - } - - if buf[0] != 5 { + if !readSOCKSVersionAndMethods(conn, buf) { return } @@ -248,94 +352,155 @@ func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { if requireAuth { wantMethod = 0x02 } - hasMethod := false - for i := 0; i < int(nmethods); i++ { - if buf[i] == wantMethod { - hasMethod = true - break - } - } - if !hasMethod { - conn.Write([]byte{5, 0xFF}) + + if !supportsMethod(buf[:nmethods], wantMethod) { + writeResponse(conn, replyUnsupportedSOCKSMethod()) return } - conn.Write([]byte{5, wantMethod}) + writeResponse(conn, []byte{5, wantMethod}) - if requireAuth { - // RFC 1929: VER ULEN UNAME PLEN PASSWD - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return - } - if buf[0] != 0x01 { - return - } - ulen := int(buf[1]) - if _, err := io.ReadFull(conn, buf[:ulen+1]); err != nil { - return - } - gotUser := string(buf[:ulen]) - plen := int(buf[ulen]) - if _, err := io.ReadFull(conn, buf[:plen]); err != nil { - return - } - gotPass := string(buf[:plen]) - if gotUser != username || gotPass != password { - conn.Write([]byte{0x01, 0x01}) - return - } - conn.Write([]byte{0x01, 0x00}) - } - - if _, err := io.ReadFull(conn, buf[:4]); err != nil { + if requireAuth && !authenticateSOCKSUser(conn, buf, username, password) { return } - if buf[1] != 1 { - conn.Write([]byte{5, 7, 0, 1, 0, 0, 0, 0, 0, 0}) + addr, port, ok := readConnectTarget(conn, buf) + if !ok { return } - var addr string - atyp := buf[3] - - switch atyp { - case 1: - if _, err := io.ReadFull(conn, buf[:4]); err != nil { - return - } - addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3]) - case 3: - if _, err := io.ReadFull(conn, buf[:1]); err != nil { - return - } - length := buf[0] - if _, err := io.ReadFull(conn, buf[:length]); err != nil { - return - } - addr = string(buf[:length]) - default: - conn.Write([]byte{5, 8, 0, 1, 0, 0, 0, 0, 0, 0}) - return - } - - if _, err := io.ReadFull(conn, buf[:2]); err != nil { - return - } - port := binary.BigEndian.Uint16(buf[:2]) - sid := c.mux.OpenStream() logger.Verbose("SOCKS5 opened stream sid=%d for %s:%d", sid, addr, port) log.Printf("[CLIENT] sid=%d SOCKS5_START %s:%d", sid, addr, port) - req := map[string]interface{}{ - "cmd": "connect", - "addr": addr, - "port": port, + if !c.sendConnectRequest(sid, addr, port) { + return } - reqData, _ := json.Marshal(req) - c.mux.SendData(sid, reqData) + if !c.waitConnectResponse(conn, sid) { + return + } + c.mux.ReadStream(sid) + writeResponse(conn, replySuccess()) + c.proxyStream(conn, sid) +} + +func readSOCKSVersionAndMethods(conn net.Conn, buf []byte) bool { + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return false + } + + return buf[0] == 5 +} + +func supportsMethod(methods []byte, wantMethod byte) bool { + for _, method := range methods { + if method == wantMethod { + return true + } + } + + return false +} + +func authenticateSOCKSUser(conn net.Conn, buf []byte, username, password string) bool { + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return false + } + if buf[0] != 0x01 { + return false + } + + ulen := int(buf[1]) + if _, err := io.ReadFull(conn, buf[:ulen+1]); err != nil { + return false + } + + gotUser := string(buf[:ulen]) + plen := int(buf[ulen]) + if _, err := io.ReadFull(conn, buf[:plen]); err != nil { + return false + } + + gotPass := string(buf[:plen]) + if gotUser != username || gotPass != password { + writeResponse(conn, replyAuthFailed()) + return false + } + + writeResponse(conn, replyAuthOK()) + return true +} + +func readConnectTarget(conn net.Conn, buf []byte) (string, uint16, bool) { + if _, err := io.ReadFull(conn, buf[:4]); err != nil { + return "", 0, false + } + + if buf[1] != 1 { + writeResponse(conn, replyCommandNotSupported()) + return "", 0, false + } + + addr, ok := readTargetAddress(conn, buf, buf[3]) + if !ok { + return "", 0, false + } + + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return "", 0, false + } + + return addr, binary.BigEndian.Uint16(buf[:2]), true +} + +func readTargetAddress(conn net.Conn, buf []byte, atyp byte) (string, bool) { + switch atyp { + case 1: + if _, err := io.ReadFull(conn, buf[:4]); err != nil { + return "", false + } + return fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3]), true + case 3: + if _, err := io.ReadFull(conn, buf[:1]); err != nil { + return "", false + } + + length := buf[0] + if _, err := io.ReadFull(conn, buf[:length]); err != nil { + return "", false + } + return string(buf[:length]), true + default: + writeResponse(conn, replyAddressNotSupported()) + return "", false + } +} + +func (c *Client) sendConnectRequest(sid uint16, addr string, port uint16) bool { + reqData, err := json.Marshal(struct { + Cmd string `json:"cmd"` + Addr string `json:"addr"` + Port uint16 `json:"port"` + }{ + Cmd: "connect", + Addr: addr, + Port: port, + }) + if err != nil { + logger.Debug("Connect request marshal error: %v", err) + return false + } + + if err := c.mux.SendData(sid, reqData); err != nil { + logger.Debug("Connect request send error: %v", err) + return false + } + + return true +} + +func (c *Client) waitConnectResponse(conn net.Conn, sid uint16) bool { dataReady := c.mux.WaitForData(sid) timeout := time.NewTimer(10 * time.Second) defer timeout.Stop() @@ -344,18 +509,19 @@ func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { case <-dataReady: stream := c.mux.GetStream(sid) if stream == nil || len(stream.RecvBuf()) == 0 { - conn.Write([]byte{5, 4, 0, 1, 0, 0, 0, 0, 0, 0}) - return + writeResponse(conn, replyHostUnreachable()) + return false } case <-timeout.C: - conn.Write([]byte{5, 4, 0, 1, 0, 0, 0, 0, 0, 0}) - return + writeResponse(conn, replyHostUnreachable()) + return false } - c.mux.ReadStream(sid) - - conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}) + return true +} +//nolint:cyclop // The stream pump handles two coordinated goroutines and shutdown races in one place. +func (c *Client) proxyStream(conn net.Conn, sid uint16) { done := make(chan struct{}) streamClosed := make(chan struct{}) @@ -365,7 +531,9 @@ func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { for { n, err := conn.Read(buf) if err != nil { - c.mux.CloseStream(sid) + if err := c.mux.CloseStream(sid); err != nil { + logger.Debug("Close stream error: %v", err) + } return } if err := c.mux.SendData(sid, buf[:n]); err != nil { @@ -387,14 +555,8 @@ func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { return case <-ticker.C: data := c.mux.ReadStream(sid) - if len(data) > 0 { - for len(data) > 0 { - n, err := conn.Write(data) - if err != nil { - return - } - data = data[n:] - } + if len(data) > 0 && !writeStreamData(conn, data) { + return } if c.mux.StreamClosed(sid) { @@ -409,3 +571,49 @@ func (c *Client) handleSOCKS5(conn net.Conn, username, password string) { case <-streamClosed: } } + +func writeStreamData(conn net.Conn, data []byte) bool { + for len(data) > 0 { + n, err := conn.Write(data) + if err != nil { + return false + } + data = data[n:] + } + + return true +} + +func writeResponse(conn net.Conn, response []byte) { + if _, err := conn.Write(response); err != nil { + logger.Debug("SOCKS5 response write error: %v", err) + } +} + +func replyUnsupportedSOCKSMethod() []byte { + return []byte{5, 0xFF} +} + +func replyAuthFailed() []byte { + return []byte{0x01, 0x01} +} + +func replyAuthOK() []byte { + return []byte{0x01, 0x00} +} + +func replyCommandNotSupported() []byte { + return []byte{5, 7, 0, 1, 0, 0, 0, 0, 0, 0} +} + +func replyAddressNotSupported() []byte { + return []byte{5, 8, 0, 1, 0, 0, 0, 0, 0, 0} +} + +func replyHostUnreachable() []byte { + return []byte{5, 4, 0, 1, 0, 0, 0, 0, 0, 0} +} + +func replySuccess() []byte { + return []byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0} +} diff --git a/internal/names/names.go b/internal/names/names.go index 02c37ff..f859d1b 100644 --- a/internal/names/names.go +++ b/internal/names/names.go @@ -1,9 +1,12 @@ +// Package names generates display names for Telemost peers. package names import ( "bufio" + "crypto/rand" _ "embed" - "math/rand/v2" + "fmt" + "math/big" "os" "strings" ) @@ -14,23 +17,12 @@ var embeddedNames string //go:embed data/surnames var embeddedSurnames string +//nolint:gochecknoglobals // Package-level state keeps the loaded name dictionaries cached for the process lifetime. var ( - firstNames []string - lastNames []string + firstNames = parseEmbedded(embeddedNames) + lastNames = parseEmbedded(embeddedSurnames) ) -var defaultFirstNames = []string{ - "Александр", "Дмитрий", "Максим", "Сергей", "Андрей", "Алексей", "Артём", "Илья", "Кирилл", "Михаил", - "Никита", "Матвей", "Роман", "Егор", "Арсений", "Иван", "Денис", "Евгений", "Даниил", "Тимофей", - "Владислав", "Игорь", "Владимир", "Павел", "Руслан", "Марк", "Константин", "Николай", "Олег", "Виктор", -} - -var defaultLastNames = []string{ - "Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров", "Соколов", "Михайлов", "Новиков", "Фёдоров", - "Морозов", "Волков", "Алексеев", "Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев", - "Орлов", "Андреев", "Макаров", "Никитин", "Захаров", "Зайцев", "Соловьёв", "Борисов", "Яковлев", "Григорьев", -} - func parseEmbedded(raw string) []string { var names []string for _, line := range strings.Split(raw, "\n") { @@ -39,15 +31,19 @@ func parseEmbedded(raw string) []string { names = append(names, line) } } + return names } func loadNames(path string) ([]string, error) { + //nolint:gosec // Paths come from local CLI/runtime configuration; loading override files is intentional here. file, err := os.Open(path) if err != nil { - return nil, err + return nil, fmt.Errorf("open names file %q: %w", path, err) } - defer file.Close() + defer func() { + _ = file.Close() + }() var names []string scanner := bufio.NewScanner(file) @@ -58,23 +54,14 @@ func loadNames(path string) ([]string, error) { } } - return names, scanner.Err() -} - -func init() { - if names := parseEmbedded(embeddedNames); len(names) > 0 { - firstNames = names - } else { - firstNames = defaultFirstNames + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan names file %q: %w", path, err) } - if names := parseEmbedded(embeddedSurnames); len(names) > 0 { - lastNames = names - } else { - lastNames = defaultLastNames - } + return names, nil } +// LoadNameFiles overrides embedded name dictionaries from local files when they are present. func LoadNameFiles(firstPath, lastPath string) error { if names, err := loadNames(firstPath); err == nil && len(names) > 0 { firstNames = names @@ -87,16 +74,24 @@ func LoadNameFiles(firstPath, lastPath string) error { return nil } +// Generate returns a random display name assembled from the currently loaded dictionaries. func Generate() string { - if len(firstNames) == 0 { - firstNames = defaultFirstNames - } - if len(lastNames) == 0 { - lastNames = defaultLastNames + if len(firstNames) == 0 || len(lastNames) == 0 { + return "anonymous user" } - first := firstNames[rand.IntN(len(firstNames))] - last := lastNames[rand.IntN(len(lastNames))] - - return first + " " + last + return firstNames[randomIndex(len(firstNames))] + " " + lastNames[randomIndex(len(lastNames))] +} + +func randomIndex(limit int) int { + if limit <= 1 { + return 0 + } + + n, err := rand.Int(rand.Reader, big.NewInt(int64(limit))) + if err != nil { + return 0 + } + + return int(n.Int64()) } diff --git a/mobile/mobile.go b/mobile/mobile.go index f8aa7cf..e60cac7 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -4,7 +4,7 @@ package mobile import ( "context" - "fmt" + "errors" "log" "sync" "time" @@ -25,12 +25,22 @@ type LogWriter interface { WriteLog(msg string) } +var ( + errAlreadyRunning = errors.New("olcRTC already running") + errRoomIDRequired = errors.New("roomID is required") + errKeyHexRequired = errors.New("keyHex is required") + errNotRunning = errors.New("olcRTC is not running") + errStoppedBeforeReady = errors.New("olcRTC stopped before becoming ready") + errStartTimedOut = errors.New("olcRTC start timed out") +) + +//nolint:gochecknoglobals // Mobile bindings expose a singleton runtime controlled by the embedding app. var ( mu sync.Mutex cancel context.CancelFunc done chan struct{} ready chan struct{} - runErr error + errRun error ) // SetProtector sets the Android VPN socket protector. @@ -57,9 +67,10 @@ func SetDebug(enabled bool) { logger.SetVerbose(enabled) if enabled { log.SetFlags(log.Ltime | log.Lshortfile) - } else { - log.SetFlags(log.Ltime) + return } + + log.SetFlags(log.Ltime) } // Start launches the olcRTC client in background. @@ -67,42 +78,52 @@ func SetDebug(enabled bool) { // keyHex: 64-char hex encryption key // socksPort: local SOCKS5 proxy port (e.g. 10808) // duo: use dual channels for higher throughput -// socksUser/socksPass: SOCKS5 credentials (empty = no auth) +// socksUser/socksPass: SOCKS5 credentials (empty = no auth). func Start(roomID, keyHex string, socksPort int, duo bool, socksUser, socksPass string) error { mu.Lock() defer mu.Unlock() - if cancel != nil { - return fmt.Errorf("olcRTC already running") - } - - if roomID == "" { - return fmt.Errorf("roomID is required") - } - if keyHex == "" { - return fmt.Errorf("keyHex is required") + switch { + case cancel != nil: + return errAlreadyRunning + case roomID == "": + return errRoomIDRequired + case keyHex == "": + return errKeyHexRequired } roomURL := "https://telemost.yandex.ru/j/" + roomID - ctx, c := context.WithCancel(context.Background()) - cancel = c + ctx, cancelFunc := context.WithCancel(context.Background()) + cancel = cancelFunc done = make(chan struct{}) ready = make(chan struct{}) localReady := ready - runErr = nil + errRun = nil var readyOnce sync.Once - go func() { - err := client.RunWithReady(ctx, roomURL, keyHex, socksPort, duo, socksUser, socksPass, func() { - readyOnce.Do(func() { - close(localReady) - }) - }) + defer cancelFunc() + + err := client.RunWithReady( + ctx, + roomURL, + keyHex, + socksPort, + duo, + "", + socksUser, + socksPass, + func() { + readyOnce.Do(func() { + close(localReady) + }) + }, + ) + mu.Lock() cancel = nil - runErr = err + errRun = err mu.Unlock() close(done) }() @@ -111,19 +132,22 @@ func Start(roomID, keyHex string, socksPort int, duo bool, socksUser, socksPass } // WaitReady blocks until the Telemost peers are connected and the local SOCKS5 listener is ready. +// +//nolint:cyclop // The control flow is intentionally linear so mobile callers can observe each startup state clearly. func WaitReady(timeoutMillis int) error { mu.Lock() r := ready d := done - err := runErr + runErr := errRun running := cancel != nil mu.Unlock() if r == nil { - if err != nil { - return err + if runErr != nil { + return runErr } - return fmt.Errorf("olcRTC is not running") + + return errNotRunning } select { @@ -133,10 +157,11 @@ func WaitReady(timeoutMillis int) error { } if !running { - if err != nil { - return err + if runErr != nil { + return runErr } - return fmt.Errorf("olcRTC stopped before becoming ready") + + return errStoppedBeforeReady } timer := time.NewTimer(time.Duration(timeoutMillis) * time.Millisecond) @@ -147,32 +172,33 @@ func WaitReady(timeoutMillis int) error { return nil case <-d: mu.Lock() - err := runErr + runErr = errRun mu.Unlock() - if err != nil { - return err + if runErr != nil { + return runErr } - return fmt.Errorf("olcRTC stopped before becoming ready") + + return errStoppedBeforeReady case <-timer.C: - return fmt.Errorf("olcRTC start timed out") + return errStartTimedOut } } // Stop gracefully stops the olcRTC client. func Stop() { mu.Lock() - c := cancel - d := done + cancelFunc := cancel + doneCh := done mu.Unlock() - if c == nil { + if cancelFunc == nil { return } - c() + cancelFunc() - if d != nil { - <-d + if doneCh != nil { + <-doneCh } } @@ -188,7 +214,7 @@ type logBridge struct { w LogWriter } -func (b *logBridge) Write(p []byte) (n int, err error) { +func (b *logBridge) Write(p []byte) (int, error) { b.w.WriteLog(string(p)) return len(p), nil } diff --git a/docker/olcrtc-entrypoint.sh b/script/docker/olcrtc-entrypoint.sh old mode 100755 new mode 100644 similarity index 100% rename from docker/olcrtc-entrypoint.sh rename to script/docker/olcrtc-entrypoint.sh diff --git a/docker/olcrtc-healthcheck.sh b/script/docker/olcrtc-healthcheck.sh old mode 100755 new mode 100644 similarity index 100% rename from docker/olcrtc-healthcheck.sh rename to script/docker/olcrtc-healthcheck.sh