Skip to content

Commit acd68a6

Browse files
yasithdevclaude
andcommitted
Add FRP tunnel provider and quality improvements
- FRP provider with XTCP P2P proxies and TCP fallback - Provider-agnostic workflow actions and REST endpoints - Quality fixes: naming consistency, stale comments, robustness - Remove unused config module and config.json - SFTP auto-reconnect, FUSE mount cleanup improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca5bd98 commit acd68a6

File tree

25 files changed

+730
-312
lines changed

25 files changed

+730
-312
lines changed

.goreleaser.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ builds:
1010
goos:
1111
- linux
1212
- darwin
13+
goarch:
14+
- amd64
15+
- arm64
1316

1417
archives:
1518
- formats: [tar.gz]

README.md

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Linkspan is a lightweight agent that runs on compute nodes (HPC clusters, cloud
1212
- **Linux mount**: Direct kernel FUSE mount via go-fuse
1313
- **macOS mount**: NFSv3 proxy (go-nfs + billy adapter) mounted via `mount_nfs`
1414
- **Jupyter Kernels** — Provision, manage, and connect to Jupyter kernels
15-
- **Remote Filesystem**REST API for file listing, reading, writing, and deletion
15+
- **VFS**Virtual filesystem modes (`sync` and `mount`) for remote data access
1616

1717
## Quick Start
1818

@@ -31,34 +31,16 @@ linkspan --port 0 --tunnel-auth-token "$TOKEN" --workflow - <<'EOF'
3131
name: "dev-setup"
3232
3333
steps:
34-
- action: "vscode.create_session"
35-
name: "Start SSH server"
36-
outputs:
37-
bind_port: "ssh_port"
38-
39-
- action: "fuse.start_server"
40-
name: "Start FUSE server"
41-
outputs:
42-
fuse_port: "fuse_server_port"
43-
4434
- action: "tunnel.devtunnel_create"
4535
name: "Create devtunnel"
4636
params:
4737
tunnel_name: "my-tunnel"
4838
expiration: "1d"
4939
auth_token: "{{.TunnelAuthToken}}"
50-
ports:
51-
- "{{.ssh_port}}"
52-
- "{{.fuse_server_port}}"
40+
server_port: "{{.ServerPort}}"
41+
ssh_port: "{{.SshPort}}"
5342
outputs:
5443
tunnel_id: "tunnel_id"
55-
56-
- action: "tunnel.devtunnel_host"
57-
name: "Host devtunnel"
58-
params:
59-
tunnel_name: "my-tunnel"
60-
auth_token: "{{.TunnelAuthToken}}"
61-
outputs:
6244
connection_url: "tunnel_url"
6345
token: "tunnel_token"
6446
EOF
@@ -74,16 +56,6 @@ linkspan --port 8080
7456

7557
This starts the REST API without running any workflow.
7658

77-
### Mount a Remote Filesystem via NFS (macOS)
78-
79-
Connect to a remote FUSE TCP server and mount it locally using NFS:
80-
81-
```bash
82-
linkspan --mount-remote --session-id my-session --server-addr 127.0.0.1:40709
83-
```
84-
85-
This creates a mount at `~/sessions/my-session/` backed by the remote filesystem. The process blocks until interrupted (SIGINT/SIGTERM), at which point it unmounts cleanly.
86-
8759
## CLI Flags
8860

8961
| Flag | Default | Description |
@@ -96,9 +68,8 @@ This creates a mount at `~/sessions/my-session/` backed by the remote filesystem
9668
| `--tunnel-retries` | `3` | Retry count for tunnel startup |
9769
| `--tunnel-retry-delay` | `2s` | Delay between tunnel retries |
9870
| `--tunnel-attempt-timeout` | `10s` | Timeout per tunnel attempt |
99-
| `--mount-remote` | `false` | Mount a remote FUSE server locally via NFS |
100-
| `--session-id` | | Session ID for mount point name (with `--mount-remote`) |
101-
| `--server-addr` | | FUSE TCP server address `host:port` (with `--mount-remote`) |
71+
| `--vfs-mode` | | VFS mode: `sync` or `mount` (also reads `CS_VFS_MODE` env) |
72+
| `--vfs-session-id` | | Session ID for VFS (also reads `CS_SESSION_ID` env) |
10273

10374
## REST API
10475

@@ -122,21 +93,6 @@ All endpoints are under `/api/v1/`.
12293
| DELETE | `/vscode/sessions/{id}` | Terminate a session |
12394
| GET | `/vscode/sessions/{id}/status` | Get session status |
12495

125-
### Remote Filesystem
126-
| Method | Path | Description |
127-
|--------|------|-------------|
128-
| GET | `/fs/list` | List files in a directory |
129-
| GET | `/fs/read` | Read a file |
130-
| POST | `/fs/write` | Write a file |
131-
| DELETE | `/fs/delete` | Delete a file |
132-
133-
### FUSE Mount
134-
| Method | Path | Description |
135-
|--------|------|-------------|
136-
| POST | `/fuse/start-server` | Start a FUSE TCP server |
137-
| POST | `/fuse/mount-remote` | Mount a remote FUSE server via NFS |
138-
| GET | `/fuse/status` | Get FUSE server/mount status |
139-
14096
### Tunnels
14197
| Method | Path | Description |
14298
|--------|------|-------------|
@@ -151,13 +107,12 @@ All endpoints are under `/api/v1/`.
151107

152108
| Action | Description | Outputs |
153109
|--------|-------------|---------|
154-
| `vscode.create_session` | Start a VS Code SSH server | `session_id`, `bind_port` |
155-
| `fuse.start_server` | Start FUSE TCP server on a random port | `fuse_port` |
156-
| `fuse.mount_remote` | Mount a remote FUSE server locally | `mount_path`, `nfs_port` |
157-
| `tunnel.devtunnel_create` | Create a Dev Tunnel with specified ports | `tunnel_id`, `tunnel_name` |
158-
| `tunnel.devtunnel_host` | Host a Dev Tunnel (start relay) | `command_id`, `connection_url`, `token` |
110+
| `tunnel.devtunnel_create` | Create a Dev Tunnel and forward ports | `tunnel_id`, `tunnel_name`, `connection_url`, `token`, `ssh_port`, `log_port` |
111+
| `tunnel.devtunnel_forward` | Forward an additional port into a Dev Tunnel | `port` |
159112
| `tunnel.devtunnel_delete` | Delete a Dev Tunnel | |
113+
| `tunnel.devtunnel_connect` | Connect to a Dev Tunnel (client side) | `command_id`, `port_map` |
160114
| `tunnel.frp_proxy_create` | Create an FRP tunnel proxy | `tunnel_name`, `tunnel_type` |
115+
| `mount.setup_overlay` | Set up an overlay mount over a remote workspace via SSHFS | `merged_path`, `cache_path`, `source_path` |
161116
| `shell.exec` | Execute a shell command | `output` |
162117

163118
## Architecture
@@ -167,9 +122,10 @@ linkspan
167122
├── main.go # Entry point, CLI flags, HTTP router, workflow orchestration
168123
├── internal/
169124
│ ├── workflow/ # Workflow engine: YAML parsing, step execution, action registry
170-
│ └── process/ # Process manager for background CLI processes
125+
│ ├── process/ # Process manager for background CLI processes
126+
│ └── logstream/ # Log broadcaster: tees log output to connected TCP clients
171127
├── subsystems/
172-
│ ├── fuse/ # FUSE-over-TCP protocol, NFS proxy, mount management
128+
│ ├── mount/ # FUSE overlay filesystem, SFTP-backed copy-up mounts
173129
│ ├── vscode/ # VS Code SSH server lifecycle
174130
│ ├── tunnel/ # Dev Tunnels SDK + CLI, FRP tunnel management
175131
│ ├── jupyter/ # Jupyter kernel provisioning
@@ -185,4 +141,4 @@ goreleaser release --snapshot --clean # Local snapshot build
185141
goreleaser release --clean # Tagged release (requires GITHUB_TOKEN)
186142
```
187143

188-
Produces archives for Linux (amd64/arm64), macOS (amd64/arm64), and Windows (amd64).
144+
Produces archives for Linux (amd64/arm64) and macOS (amd64/arm64).

config.json

Lines changed: 0 additions & 6 deletions
This file was deleted.

internal/config/config.go

Lines changed: 0 additions & 58 deletions
This file was deleted.

internal/logstream/logstream.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,24 @@ func (b *Broadcaster) Write(p []byte) (int, error) {
2929
n, err := b.dest.Write(p)
3030

3131
b.mu.RLock()
32+
var dead []net.Conn
3233
for c := range b.clients {
33-
_, writeErr := c.Write(p)
34-
if writeErr != nil {
35-
// Mark for removal but don't modify map during iteration
36-
go b.remove(c)
34+
if _, writeErr := c.Write(p); writeErr != nil {
35+
dead = append(dead, c)
3736
}
3837
}
3938
b.mu.RUnlock()
4039

41-
return n, err
42-
}
40+
if len(dead) > 0 {
41+
b.mu.Lock()
42+
for _, c := range dead {
43+
delete(b.clients, c)
44+
c.Close()
45+
}
46+
b.mu.Unlock()
47+
}
4348

44-
func (b *Broadcaster) remove(c net.Conn) {
45-
b.mu.Lock()
46-
delete(b.clients, c)
47-
b.mu.Unlock()
48-
c.Close()
49+
return n, err
4950
}
5051

5152
// ListenAndServe starts a TCP listener on addr and accepts clients that
@@ -73,7 +74,10 @@ func (b *Broadcaster) ListenAndServe(addr string) (net.Listener, error) {
7374
buf := make([]byte, 256)
7475
for {
7576
if _, err := c.Read(buf); err != nil {
76-
b.remove(c)
77+
b.mu.Lock()
78+
delete(b.clients, c)
79+
b.mu.Unlock()
80+
c.Close()
7781
return
7882
}
7983
}

internal/process/process_manager.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,11 @@ func (pm *ProcessManager) GetInfo(id string) (ManagedProcess, error) {
4141
pm.mu.Lock()
4242
defer pm.mu.Unlock()
4343

44-
// just return info of the first process as a placeholder
45-
for _, mp := range pm.procs {
46-
if mp.ID == id {
47-
return *mp, nil
48-
}
44+
mp, ok := pm.procs[id]
45+
if !ok {
46+
return ManagedProcess{}, fmt.Errorf("process %s not found", id)
4947
}
50-
return ManagedProcess{}, fmt.Errorf("no processes found")
48+
return *mp, nil
5149
}
5250

5351
// Start registers and starts the given *exec.Cmd asynchronously and returns an id.

0 commit comments

Comments
 (0)