Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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://<host-lan-ip>:<wsPort>/ws`.

---

## Network Compatibility

* **Optimal:** Fiber, Home Wi-Fi, 4G/5G mobile hotspots.
Expand Down
158 changes: 120 additions & 38 deletions cmd/roj1/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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()

Expand All @@ -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 {
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions internal/signaling/signaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions internal/signaling/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 7 additions & 1 deletion internal/transport/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down