diff --git a/README.md b/README.md index 73d090a..1f79a79 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ๐Ÿฎ Roj1 (Roji) ![CI Status](https://github.com/1ureka/roj1/actions/workflows/ci.yml/badge.svg) -![Go Report Card](https://goreportcard.com/badge/github.com/1ureka/roj1) +[![Go Report Card](https://goreportcard.com/badge/github.com/1ureka/roj1)](https://goreportcard.com/report/github.com/1ureka/roj1) `Roj1` (pronounced as *Roji*, Japanese for "alleyway") is a lightweight tool that carves a private, **1-to-1** path between any two points on the internet. Share Minecraft servers, AI APIs, or local databases directly and securelyโ€”without port forwarding, static IPs, or middleman fees. @@ -56,6 +56,35 @@ The **Host** needs [Visual Studio Code](https://code.visualstudio.com/) installe --- +## CLI Arguments + +For automation or LAN setups, **Roj1** can be launched entirely from the command line, bypassing the interactive prompts. If `-role` is omitted, the tool falls back to the default interactive mode. + +| Flag | Description | Applies To | +| --- | --- | --- | +| `-role` | `host` or `client` | Both | +| `-port` | Target port (Host) or virtual service port (Client) | Both | +| `-wsPort` | WebSocket signaling server port (default: random) | Host | +| `-wsUrl` | WebSocket URL to connect to | Client | +| `-wsListen` | Listen on all network interfaces (LAN-accessible) | Host | +| `-debug` | Enable debug logging | Both | + +**Host example:** + +```sh +roj1 -role host -port 25565 -wsPort 9000 -wsListen +``` + +**Client example:** + +```sh +roj1 -role client -port 25565 -wsUrl ws://192.168.1.10:9000/ws +``` + +> **TIP:** When both machines are on the same local network, use `-wsListen` on the Host to make the WebSocket signaling server directly reachable via LAN IP. This eliminates the need for VS Code Port Forwarding entirely โ€” the Client simply connects using `ws://:/ws`. + +--- + ## Network Compatibility * **Optimal:** Fiber, Home Wi-Fi, 4G/5G mobile hotspots. diff --git a/cmd/roj1/main.go b/cmd/roj1/main.go index 172b102..b128180 100644 --- a/cmd/roj1/main.go +++ b/cmd/roj1/main.go @@ -3,6 +3,9 @@ // This tool creates a P2P tunnel over WebRTC DataChannel, forwarding a remote // TCP service to a local port. No relay servers are needed after the signaling // phase (which uses WebSocket). +// +// It can be launched interactively (no flags) or non-interactively via CLI +// flags (-role, -port, -wsPort, -wsUrl, -wsListen). package main import ( @@ -29,6 +32,12 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + // CLI flags. + role := flag.String("role", "", "Role: host or client") + port := flag.Int("port", 0, "Target port (host) or virtual service port (client), 1~65535") + wsPortFlag := flag.Int("wsPort", 0, "WebSocket signaling server port (host only)") + wsURLFlag := flag.String("wsUrl", "", "WebSocket URL to connect to (client only)") + wsListenFlag := flag.Bool("wsListen", false, "Listen on all network interfaces (host only, for LAN access)") debugMode := flag.Bool("debug", false, "Enable debug logging") flag.Parse() @@ -39,56 +48,134 @@ func main() { pterm.Info.Println(fmt.Sprintf("Roj1 โ€” v%s", version)) pterm.Println() - role, _ := pterm.DefaultInteractiveSelect. - WithOptions([]string{"Host โ€” Expose a local service", "Client โ€” Connect to a remote host"}). - WithDefaultText("Select your role"). - Show() - - pterm.Println() + switch *role { + case "": + // No -role flag โ†’ interactive mode. + runInteractive(ctx) - // ---- Run the appropriate tunnel logic based on the selected role ------------------------------ + case "host": + if *port < 1 || *port > 65535 { + util.LogError("invalid or missing -port (must be 1~65535)") + os.Exit(1) + } - if strings.HasPrefix(role, "Host") { - port := askPort("Target port to forward (1 ~ 65535)") + var wsAddr string - tr, err := signaling.EstablishAsHost(ctx) - if err != nil { - util.LogError("failed to establish tunnel: %v", err) - os.Exit(1) + switch { + case *wsListenFlag: + wsAddr = fmt.Sprintf(":%d", *wsPortFlag) + case *wsPortFlag > 0: + wsAddr = fmt.Sprintf("127.0.0.1:%d", *wsPortFlag) + default: + wsAddr = ":0" } - defer tr.Close() - util.StartStatsReporter(ctx) - util.LogSuccess("P2P tunnel established โ€” forwarding traffic to 127.0.0.1:%d", port) + runHost(ctx, *port, wsAddr) - if err := adapter.RunAsHost(ctx, tr, fmt.Sprintf("127.0.0.1:%d", port)); err != nil { - util.LogError("failed to handle tunnel connection: %v", err) + case "client": + if *port < 1 || *port > 65535 { + util.LogError("invalid or missing -port (must be 1~65535)") os.Exit(1) } - } else { - wsURL := askURL() - port := askPort("Local port for virtual service (1 ~ 65535)") - tr, err := signaling.EstablishAsClient(ctx, wsURL) - if err != nil { - util.LogError("failed to establish tunnel: %v", err) + if *wsURLFlag == "" { + util.LogError("missing -wsUrl for client role") os.Exit(1) } - defer tr.Close() - util.StartStatsReporter(ctx) - util.LogSuccess("P2P tunnel established โ€” forwarding traffic to Host") + wsURL, err := normalizeWSURL(*wsURLFlag) - if err := adapter.RunAsClient(ctx, tr, fmt.Sprintf("127.0.0.1:%d", port)); err != nil { - util.LogError("failed to handle tunnel connection: %v", err) + if err != nil { + util.LogError("%v", err) os.Exit(1) } + + runClient(ctx, *port, wsURL) + + default: + util.LogError("invalid -role: must be 'host' or 'client'") + os.Exit(1) } util.LogInfo("successfully closed tunnel connection") } -// ----------- Helper Functions ------------------------------------------------------ +// --------------------------------------------------------------------------- +// Run modes +// --------------------------------------------------------------------------- + +// runInteractive falls back to the original interactive prompts when no -role +// flag is provided. +func runInteractive(ctx context.Context) { + role, _ := pterm.DefaultInteractiveSelect. + WithOptions([]string{"Host โ€” Expose a local service", "Client โ€” Connect to a remote host"}). + WithDefaultText("Select your role"). + Show() + + pterm.Println() + + if strings.HasPrefix(role, "Host") { + port := askPort("Target port to forward (1 ~ 65535)") + runHost(ctx, port, ":0") + } else { + wsURL := askURL() + port := askPort("Local port for virtual service (1 ~ 65535)") + runClient(ctx, port, wsURL) + } +} + +// runHost executes the host-side tunnel logic. +func runHost(ctx context.Context, port int, wsAddr string) { + tr, err := signaling.EstablishAsHost(ctx, wsAddr) + if err != nil { + util.LogError("failed to establish tunnel: %v", err) + os.Exit(1) + } + defer tr.Close() + + util.StartStatsReporter(ctx) + util.LogSuccess("P2P tunnel established โ€” forwarding traffic to 127.0.0.1:%d", port) + + if err := adapter.RunAsHost(ctx, tr, fmt.Sprintf("127.0.0.1:%d", port)); err != nil { + util.LogError("failed to handle tunnel connection: %v", err) + os.Exit(1) + } +} + +// runClient executes the client-side tunnel logic. +func runClient(ctx context.Context, port int, wsURL string) { + tr, err := signaling.EstablishAsClient(ctx, wsURL) + if err != nil { + util.LogError("failed to establish tunnel: %v", err) + os.Exit(1) + } + defer tr.Close() + + util.StartStatsReporter(ctx) + util.LogSuccess("P2P tunnel established โ€” forwarding traffic to Host") + + if err := adapter.RunAsClient(ctx, tr, fmt.Sprintf("127.0.0.1:%d", port)); err != nil { + util.LogError("failed to handle tunnel connection: %v", err) + os.Exit(1) + } +} + +// --------------------------------------------------------------------------- +// Helper Functions +// --------------------------------------------------------------------------- + +// normalizeWSURL validates and normalizes a raw WebSocket URL string. +func normalizeWSURL(raw string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Host == "" { + return "", fmt.Errorf("invalid WebSocket URL: %s", raw) + } + scheme := "wss" + if u.Scheme == "ws" || u.Scheme == "wss" { + scheme = u.Scheme + } + return fmt.Sprintf("%s://%s/ws", scheme, u.Host), nil +} // askPort prompts the user for a port number until a valid one is entered. func askPort(prompt string) int { @@ -115,15 +202,10 @@ func askURL() string { WithDefaultText("WebSocket URL (e.g. wss://***.asse.devtunnels.ms/ws)"). Show() - url, err := url.Parse(strings.TrimSpace(raw)) - if err == nil && url.Host != "" { - var scheme = "wss" - if url.Scheme == "ws" || url.Scheme == "wss" { - scheme = url.Scheme - } - + wsURL, err := normalizeWSURL(raw) + if err == nil { pterm.Println() - return fmt.Sprintf("%s://%s/ws", scheme, url.Host) + return wsURL } pterm.Println() diff --git a/internal/signaling/signaling.go b/internal/signaling/signaling.go index d3b8010..8f3a5da 100644 --- a/internal/signaling/signaling.go +++ b/internal/signaling/signaling.go @@ -17,21 +17,21 @@ import ( ) // EstablishAsHost executes the full host-side signaling flow: -// 1. Start a WS server on a random port +// 1. Start a WS server on wsAddr (e.g. ":0" for random port) // 2. Wait for the client to connect // 3. Create a Transport // 4. Perform SDP/ICE exchange // 5. Wait for the DataChannel to be ready // 6. Close the WS server and connection (resource cleanup) // 7. Return the ready Transport -func EstablishAsHost(ctx context.Context) (*transport.Transport, error) { +func EstablishAsHost(ctx context.Context, wsAddr string) (*transport.Transport, error) { // 1. Start WS server. spinner, _ := pterm.DefaultSpinner. WithRemoveWhenDone(true). Start("starting WebSocket signaling server...") srv := &server{connCh: make(chan *websocket.Conn, 1)} - wsPort, err := srv.start() + wsPort, err := srv.start(wsAddr) if err != nil { spinner.Fail("failed to start WebSocket server") return nil, err diff --git a/internal/signaling/ws.go b/internal/signaling/ws.go index 898c839..08f1d43 100644 --- a/internal/signaling/ws.go +++ b/internal/signaling/ws.go @@ -19,9 +19,10 @@ type server struct { connCh chan *websocket.Conn } -// start begins listening on a random port. Returns the assigned port number. -func (s *server) start() (int, error) { - listener, err := net.Listen("tcp", ":0") +// start begins listening on the given address (e.g. ":0", "127.0.0.1:9000"). +// Returns the assigned port number. +func (s *server) start(addr string) (int, error) { + listener, err := net.Listen("tcp", addr) if err != nil { return 0, fmt.Errorf("failed to start WS server: %w", err) } diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 9bc74c1..f64f2d3 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -73,12 +73,18 @@ func NewTransport(ctx context.Context) (*Transport, error) { tCancel() }) - // Record PC state (informational only). + // Record PC state; auto-close on "failed" (pion/webrtc does not + // propagate failed โ†’ DC close like browsers do). pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { util.LogInfo("PeerConnection state changed โ†’ %s", state) t.mu.Lock() t.pcState = state t.mu.Unlock() + + if state == webrtc.PeerConnectionStateFailed { + util.LogWarning("PeerConnection entered failed state โ€” closing transport") + tCancel() + } }) // Start the sender goroutine.