Skip to content

Commit 7eaf209

Browse files
yasithdevclaude
andcommitted
feat: overlay mount with local linkspan origin
Add SFTP subsystem to SSH server, start SSH at boot, forward both ports through devtunnel. Parse port map from devtunnel connect output. Add mount.setup_overlay workflow action (sshfs + fuse-overlayfs). Add in-memory metadata store API. Read CS_LOCAL_* env vars for overlay workflow variables. Remove vscode.create_session action (SSH is always-on). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d4aa10 commit 7eaf209

File tree

9 files changed

+266
-44
lines changed

9 files changed

+266
-44
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2525
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
2626
github.com/klauspost/reedsolomon v1.12.0 // indirect
27+
github.com/kr/fs v0.1.0 // indirect
2728
github.com/kr/text v0.2.0 // indirect
2829
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
2930
github.com/pion/dtls/v2 v2.2.7 // indirect
@@ -33,6 +34,7 @@ require (
3334
github.com/pion/transport/v3 v3.0.1 // indirect
3435
github.com/pires/go-proxyproto v0.7.0 // indirect
3536
github.com/pkg/errors v0.9.1 // indirect
37+
github.com/pkg/sftp v1.13.10 // indirect
3638
github.com/quic-go/quic-go v0.55.0 // indirect
3739
github.com/rodaine/table v1.2.0 // indirect
3840
github.com/rogpeppe/go-internal v1.12.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/4
6363
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
6464
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
6565
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
66+
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
67+
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
6668
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
6769
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
6870
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -87,6 +89,8 @@ github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwy
8789
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
8890
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
8991
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
92+
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
93+
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
9094
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
9195
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9296
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=

internal/workflow/actions.go

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,22 @@ import (
44
"fmt"
55
"log"
66
"os/exec"
7+
"strconv"
78
"strings"
89

10+
"github.com/cyber-shuttle/linkspan/subsystems/mount"
911
tunnel "github.com/cyber-shuttle/linkspan/subsystems/tunnel"
10-
vscode "github.com/cyber-shuttle/linkspan/subsystems/vscode"
11-
utils "github.com/cyber-shuttle/linkspan/utils"
1212
)
1313

1414
// registerBuiltinActions populates a Registry with all built-in action wrappers.
1515
func registerBuiltinActions(r *Registry) {
16-
r.Register("vscode.create_session", actionVSCodeCreateSession)
1716
r.Register("tunnel.devtunnel_create", actionDevTunnelCreate)
1817
r.Register("tunnel.devtunnel_forward", actionDevTunnelForward)
1918
r.Register("tunnel.devtunnel_delete", actionDevTunnelDelete)
2019
r.Register("tunnel.devtunnel_connect", actionDevTunnelConnect)
2120
r.Register("tunnel.frp_proxy_create", actionFrpProxyCreate)
2221
r.Register("shell.exec", actionShellExec)
23-
}
24-
25-
// --- vscode.create_session ---
26-
27-
func actionVSCodeCreateSession(params map[string]any) (*ActionResult, error) {
28-
port, err := utils.GetAvailablePort()
29-
if err != nil {
30-
return nil, fmt.Errorf("get available port: %w", err)
31-
}
32-
33-
sessionID := fmt.Sprintf("wf-%d", port)
34-
addr := fmt.Sprintf(":%d", port)
35-
36-
vscode.StartSSHServerForVSCodeConnection(sessionID, addr)
37-
38-
result := ActionResult{
39-
"session_id": sessionID,
40-
"bind_port": port,
41-
}
42-
return &result, nil
22+
r.Register("mount.setup_overlay", actionSetupOverlay)
4323
}
4424

4525
// --- tunnel.devtunnel_create ---
@@ -58,8 +38,9 @@ func actionDevTunnelCreate(params map[string]any) (*ActionResult, error) {
5838
return nil, fmt.Errorf("tunnel.devtunnel_create: auth_token is required")
5939
}
6040
serverPort := toInt(params["server_port"])
41+
sshPort := toInt(params["ssh_port"])
6142

62-
conn, err := tunnel.DevTunnelCreate(tunnelName, expiration, authToken, serverPort)
43+
conn, err := tunnel.DevTunnelCreate(tunnelName, expiration, authToken, serverPort, sshPort)
6344
if err != nil {
6445
return nil, err
6546
}
@@ -69,6 +50,7 @@ func actionDevTunnelCreate(params map[string]any) (*ActionResult, error) {
6950
"tunnel_name": conn.DevTunnelInfo.TunnelName,
7051
"connection_url": conn.ConnectionURL,
7152
"token": conn.Token,
53+
"ssh_port": sshPort,
7254
}
7355
return &result, nil
7456
}
@@ -123,13 +105,20 @@ func actionDevTunnelConnect(params map[string]any) (*ActionResult, error) {
123105
return nil, fmt.Errorf("tunnel.devtunnel_connect: access_token is required")
124106
}
125107

126-
cmdID, err := tunnel.DevTunnelConnect(tunnelID, accessToken)
108+
cmdID, portMap, err := tunnel.DevTunnelConnect(tunnelID, accessToken)
127109
if err != nil {
128110
return nil, err
129111
}
130112

113+
// Convert port map to string-keyed map for template access
114+
portMapStr := make(map[string]any)
115+
for remote, local := range portMap {
116+
portMapStr[strconv.Itoa(remote)] = local
117+
}
118+
131119
result := ActionResult{
132120
"command_id": cmdID,
121+
"port_map": portMapStr,
133122
}
134123
return &result, nil
135124
}
@@ -183,6 +172,35 @@ func actionShellExec(params map[string]any) (*ActionResult, error) {
183172
return &result, nil
184173
}
185174

175+
// --- mount.setup_overlay ---
176+
177+
func actionSetupOverlay(params map[string]any) (*ActionResult, error) {
178+
sessionID, _ := params["session_id"].(string)
179+
if sessionID == "" {
180+
return nil, fmt.Errorf("mount.setup_overlay: session_id is required")
181+
}
182+
localWorkspace, _ := params["local_workspace"].(string)
183+
if localWorkspace == "" {
184+
return nil, fmt.Errorf("mount.setup_overlay: local_workspace is required")
185+
}
186+
localSshPort := toInt(params["local_ssh_port"])
187+
if localSshPort == 0 {
188+
return nil, fmt.Errorf("mount.setup_overlay: local_ssh_port is required")
189+
}
190+
191+
overlay, err := mount.SetupOverlay(sessionID, localSshPort, localWorkspace)
192+
if err != nil {
193+
return nil, fmt.Errorf("mount.setup_overlay: %w", err)
194+
}
195+
196+
result := ActionResult{
197+
"merged_path": overlay.MergedDir,
198+
"cache_path": overlay.CacheDir,
199+
"source_path": overlay.SourceDir,
200+
}
201+
return &result, nil
202+
}
203+
186204
// --- helpers ---
187205

188206
// toInt converts a param value to int, handling YAML's default float64/int types.

main.go

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import (
55
"encoding/json"
66
"flag"
77
"fmt"
8+
"io"
89
"log"
910
"net"
1011
"net/http"
1112
"os"
1213
"os/signal"
1314
"strings"
15+
"sync"
1416
"syscall"
1517
"time"
1618

@@ -20,6 +22,7 @@ import (
2022
tunnel "github.com/cyber-shuttle/linkspan/subsystems/tunnel"
2123
"github.com/cyber-shuttle/linkspan/subsystems/vfs"
2224
vscode "github.com/cyber-shuttle/linkspan/subsystems/vscode"
25+
utils "github.com/cyber-shuttle/linkspan/utils"
2326
"github.com/gorilla/mux"
2427
)
2528

@@ -29,6 +32,12 @@ var (
2932
vfsMountProvider *vfs.MountProvider
3033
)
3134

35+
// In-memory metadata store (key → arbitrary JSON value).
36+
var (
37+
metadataStore = make(map[string]json.RawMessage)
38+
metadataMu sync.RWMutex
39+
)
40+
3241
func main() {
3342

3443
// parse CLI flags
@@ -136,6 +145,45 @@ func main() {
136145
json.NewEncoder(w).Encode(workflowEngine.Status())
137146
}).Methods("GET")
138147

148+
// Metadata store — in-memory key-value for shared state
149+
api.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
150+
metadataMu.RLock()
151+
defer metadataMu.RUnlock()
152+
w.Header().Set("Content-Type", "application/json")
153+
json.NewEncoder(w).Encode(metadataStore)
154+
}).Methods("GET")
155+
156+
api.HandleFunc("/metadata/{key:.+}", func(w http.ResponseWriter, r *http.Request) {
157+
key := mux.Vars(r)["key"]
158+
switch r.Method {
159+
case "GET":
160+
metadataMu.RLock()
161+
val, ok := metadataStore[key]
162+
metadataMu.RUnlock()
163+
if !ok {
164+
http.NotFound(w, r)
165+
return
166+
}
167+
w.Header().Set("Content-Type", "application/json")
168+
w.Write(val)
169+
case "PUT":
170+
body, err := io.ReadAll(r.Body)
171+
if err != nil {
172+
http.Error(w, err.Error(), http.StatusBadRequest)
173+
return
174+
}
175+
metadataMu.Lock()
176+
metadataStore[key] = json.RawMessage(body)
177+
metadataMu.Unlock()
178+
w.WriteHeader(http.StatusNoContent)
179+
case "DELETE":
180+
metadataMu.Lock()
181+
delete(metadataStore, key)
182+
metadataMu.Unlock()
183+
w.WriteHeader(http.StatusNoContent)
184+
}
185+
}).Methods("GET", "PUT", "DELETE")
186+
139187
// Use the configured server host and port from CLI flags.
140188
// Port 0 means "let the OS pick a free port".
141189
serverPort := *serverPortFlag
@@ -163,6 +211,16 @@ func main() {
163211
}
164212
log.Printf("listening on %s:%d", serverHost, serverPort)
165213

214+
// Start SSH daemon on a random port
215+
sshPort, err := utils.GetAvailablePort()
216+
if err != nil {
217+
log.Fatalf("failed to get available port for SSH: %v", err)
218+
}
219+
sshAddr := fmt.Sprintf("0.0.0.0:%d", sshPort)
220+
sshSessionID := fmt.Sprintf("main-%d", sshPort)
221+
vscode.StartSSHServerForVSCodeConnection(sshSessionID, sshAddr)
222+
log.Printf("SSH server listening on %s", sshAddr)
223+
166224
// Run workflow if specified. Use "-" to read from stdin.
167225
if *workflowFile != "" {
168226
var wf *workflow.WorkflowConfig
@@ -176,10 +234,16 @@ func main() {
176234
log.Fatalf("workflow: %v", err)
177235
}
178236
workflowEngine = workflow.NewEngine(workflow.DefaultRegistry(), map[string]any{
179-
"Timestamp": time.Now().Unix(),
180-
"ServerPort": serverPort,
181-
"ServerHost": serverHost,
182-
"TunnelAuthToken": *tunnelAuthToken,
237+
"Timestamp": time.Now().Unix(),
238+
"ServerPort": serverPort,
239+
"SshPort": sshPort,
240+
"ServerHost": serverHost,
241+
"TunnelAuthToken": *tunnelAuthToken,
242+
"LocalTunnelID": os.Getenv("CS_LOCAL_TUNNEL_ID"),
243+
"LocalTunnelToken": os.Getenv("CS_LOCAL_TUNNEL_TOKEN"),
244+
"LocalSshPort": os.Getenv("CS_LOCAL_SSH_PORT"),
245+
"LocalWorkspace": os.Getenv("CS_LOCAL_WORKSPACE"),
246+
"SessionID": os.Getenv("CS_SESSION_ID"),
183247
})
184248
go func() {
185249
if err := workflowEngine.Run(wf); err != nil {
@@ -218,7 +282,7 @@ func main() {
218282

219283
ch := make(chan error, 1)
220284
go func() {
221-
conn, err := tunnel.DevTunnelCreate(tunnelName, "1d", authToken, serverPort)
285+
conn, err := tunnel.DevTunnelCreate(tunnelName, "1d", authToken, serverPort, sshPort)
222286
if err != nil {
223287
ch <- err
224288
return

subsystems/mount/overlay.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package mount
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
10+
pm "github.com/cyber-shuttle/linkspan/internal/process"
11+
)
12+
13+
// OverlayMount holds the state for a single overlay filesystem setup.
14+
type OverlayMount struct {
15+
SessionID string
16+
SourceDir string // sshfs mount of local workspace (lower)
17+
CacheDir string // mutagen-warmed cache (upper)
18+
WorkDir string // overlayfs workdir
19+
MergedDir string // final merged view
20+
SshfsCmdID string // process manager ID for sshfs
21+
OverlayCmdID string // process manager ID for fuse-overlayfs
22+
}
23+
24+
// SetupOverlay creates the overlay filesystem:
25+
// 1. sshfs mounts the local workspace via tunnel
26+
// 2. fuse-overlayfs merges lower (sshfs) + upper (cache)
27+
func SetupOverlay(sessionID string, localSshPort int, localWorkspace string) (*OverlayMount, error) {
28+
home, err := os.UserHomeDir()
29+
if err != nil {
30+
return nil, fmt.Errorf("overlay: get home dir: %w", err)
31+
}
32+
33+
m := &OverlayMount{
34+
SessionID: sessionID,
35+
SourceDir: filepath.Join(os.TempDir(), fmt.Sprintf("cs-source-%s", sessionID)),
36+
CacheDir: filepath.Join(home, "sessions", sessionID),
37+
WorkDir: filepath.Join(home, "sessions", sessionID, ".overlay-work"),
38+
MergedDir: filepath.Join(home, "overlay", sessionID),
39+
}
40+
41+
// Create all directories
42+
for _, dir := range []string{m.SourceDir, m.CacheDir, m.WorkDir, m.MergedDir} {
43+
if err := os.MkdirAll(dir, 0755); err != nil {
44+
return nil, fmt.Errorf("overlay: mkdir %s: %w", dir, err)
45+
}
46+
}
47+
48+
// 1. sshfs mount local workspace via tunnel
49+
sshfsArgs := []string{
50+
fmt.Sprintf("localhost:%s", localWorkspace),
51+
m.SourceDir,
52+
"-p", fmt.Sprintf("%d", localSshPort),
53+
"-o", "StrictHostKeyChecking=no",
54+
"-o", "UserKnownHostsFile=/dev/null",
55+
"-o", "reconnect",
56+
"-o", "ServerAliveInterval=15",
57+
"-o", "ServerAliveCountMax=3",
58+
}
59+
60+
sshfsCmd := exec.Command("sshfs", sshfsArgs...)
61+
sshfsCmdID, err := pm.GlobalProcessManager.Start(sshfsCmd)
62+
if err != nil {
63+
return nil, fmt.Errorf("overlay: start sshfs: %w", err)
64+
}
65+
m.SshfsCmdID = sshfsCmdID
66+
log.Printf("[overlay] sshfs mounted %s on port %d → %s", localWorkspace, localSshPort, m.SourceDir)
67+
68+
// 2. fuse-overlayfs
69+
overlayArgs := []string{
70+
"-o", fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", m.SourceDir, m.CacheDir, m.WorkDir),
71+
m.MergedDir,
72+
}
73+
74+
overlayCmd := exec.Command("fuse-overlayfs", overlayArgs...)
75+
overlayCmdID, err := pm.GlobalProcessManager.Start(overlayCmd)
76+
if err != nil {
77+
// Clean up sshfs on failure
78+
_ = pm.GlobalProcessManager.Kill(sshfsCmdID)
79+
return nil, fmt.Errorf("overlay: start fuse-overlayfs: %w", err)
80+
}
81+
m.OverlayCmdID = overlayCmdID
82+
log.Printf("[overlay] fuse-overlayfs merged at %s (lower=%s, upper=%s)", m.MergedDir, m.SourceDir, m.CacheDir)
83+
84+
return m, nil
85+
}
86+
87+
// Teardown unmounts the overlay and sshfs.
88+
func (m *OverlayMount) Teardown() {
89+
if m.OverlayCmdID != "" {
90+
_ = pm.GlobalProcessManager.Kill(m.OverlayCmdID)
91+
log.Printf("[overlay] stopped fuse-overlayfs for %s", m.SessionID)
92+
}
93+
// fusermount -u to cleanly unmount
94+
_ = exec.Command("fusermount", "-u", m.MergedDir).Run()
95+
_ = exec.Command("fusermount", "-u", m.SourceDir).Run()
96+
97+
if m.SshfsCmdID != "" {
98+
_ = pm.GlobalProcessManager.Kill(m.SshfsCmdID)
99+
log.Printf("[overlay] stopped sshfs for %s", m.SessionID)
100+
}
101+
}

subsystems/tunnel/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func CreateDevTunnel(w http.ResponseWriter, r *http.Request) {
8888
return
8989
}
9090

91-
conn, err := DevTunnelCreate(req.TunnelName, req.Expiration, req.AuthToken, req.ServerPort)
91+
conn, err := DevTunnelCreate(req.TunnelName, req.Expiration, req.AuthToken, req.ServerPort, 0)
9292
if err != nil {
9393
utils.RespondJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
9494
return

0 commit comments

Comments
 (0)