Skip to content

Commit 72c5d27

Browse files
yasithdevclaude
andcommitted
Merge devtunnel create+host into single operation with server port
DevTunnelCreate now creates the tunnel, hosts the relay, and forwards the linkspan server port in one step — giving the client direct HTTP access to linkspan immediately. Additional service ports (SSH, etc.) are added incrementally via DevTunnelForward. - Remove DevTunnelHost as separate function (merged into DevTunnelCreate) - Remove tunnel.devtunnel_host workflow action - Remove unused toIntSlice helper - Workflow is now 3 steps: create → create_session → forward - Add binaries to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d7816a commit 72c5d27

File tree

7 files changed

+70
-127
lines changed

7 files changed

+70
-127
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ tmp/
22
bin/
33
# Added by goreleaser init:
44
dist/
5+
linkspan
6+
linkspan-linux-amd64

examples/cs-bridge-workflow.yaml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,9 @@ steps:
77
tunnel_name: "linkspan-tunnel"
88
expiration: "1d"
99
auth_token: "{{.TunnelAuthToken}}"
10+
server_port: "{{.ServerPort}}"
1011
outputs:
1112
tunnel_id: "tunnel_id"
12-
13-
- action: "tunnel.devtunnel_host"
14-
name: "Host devtunnel"
15-
params:
16-
tunnel_name: "linkspan-tunnel"
17-
auth_token: "{{.TunnelAuthToken}}"
18-
outputs:
1913
connection_url: "tunnel_url"
2014
token: "tunnel_token"
2115

internal/workflow/actions.go

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
func registerBuiltinActions(r *Registry) {
1616
r.Register("vscode.create_session", actionVSCodeCreateSession)
1717
r.Register("tunnel.devtunnel_create", actionDevTunnelCreate)
18-
r.Register("tunnel.devtunnel_host", actionDevTunnelHost)
1918
r.Register("tunnel.devtunnel_forward", actionDevTunnelForward)
2019
r.Register("tunnel.devtunnel_delete", actionDevTunnelDelete)
2120
r.Register("tunnel.devtunnel_connect", actionDevTunnelConnect)
@@ -44,6 +43,9 @@ func actionVSCodeCreateSession(params map[string]any) (*ActionResult, error) {
4443
}
4544

4645
// --- tunnel.devtunnel_create ---
46+
// Creates a tunnel, hosts the relay, and forwards the server port so the client
47+
// can communicate with linkspan immediately. Additional ports are added later
48+
// via tunnel.devtunnel_forward.
4749

4850
func actionDevTunnelCreate(params map[string]any) (*ActionResult, error) {
4951
tunnelName, _ := params["tunnel_name"].(string)
@@ -55,35 +57,16 @@ func actionDevTunnelCreate(params map[string]any) (*ActionResult, error) {
5557
if authToken == "" {
5658
return nil, fmt.Errorf("tunnel.devtunnel_create: auth_token is required")
5759
}
60+
serverPort := toInt(params["server_port"])
5861

59-
info, err := tunnel.DevTunnelCreate(tunnelName, expiration, authToken)
62+
conn, err := tunnel.DevTunnelCreate(tunnelName, expiration, authToken, serverPort)
6063
if err != nil {
6164
return nil, err
6265
}
6366

6467
result := ActionResult{
65-
"tunnel_id": info.QualifiedID(),
66-
"tunnel_name": info.TunnelName,
67-
}
68-
return &result, nil
69-
}
70-
71-
// --- tunnel.devtunnel_host ---
72-
73-
func actionDevTunnelHost(params map[string]any) (*ActionResult, error) {
74-
tunnelName, _ := params["tunnel_name"].(string)
75-
authToken, _ := params["auth_token"].(string)
76-
if authToken == "" {
77-
return nil, fmt.Errorf("tunnel.devtunnel_host: auth_token is required")
78-
}
79-
80-
cmdID, conn, err := tunnel.DevTunnelHost(tunnelName, authToken)
81-
if err != nil {
82-
return nil, err
83-
}
84-
85-
result := ActionResult{
86-
"command_id": cmdID,
68+
"tunnel_id": conn.DevTunnelInfo.QualifiedID(),
69+
"tunnel_name": conn.DevTunnelInfo.TunnelName,
8770
"connection_url": conn.ConnectionURL,
8871
"token": conn.Token,
8972
}
@@ -202,22 +185,6 @@ func actionShellExec(params map[string]any) (*ActionResult, error) {
202185

203186
// --- helpers ---
204187

205-
// toIntSlice converts a param value to []int, handling YAML-decoded []any.
206-
func toIntSlice(v any) []int {
207-
switch val := v.(type) {
208-
case []any:
209-
out := make([]int, 0, len(val))
210-
for _, elem := range val {
211-
out = append(out, toInt(elem))
212-
}
213-
return out
214-
case []int:
215-
return val
216-
default:
217-
return nil
218-
}
219-
}
220-
221188
// toInt converts a param value to int, handling YAML's default float64/int types.
222189
func toInt(v any) int {
223190
switch val := v.(type) {

main.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,15 @@ func main() {
196196

197197
ch := make(chan error, 1)
198198
go func() {
199-
_, err := tunnel.DevTunnelCreate(tunnelName, "1d", authToken)
199+
conn, err := tunnel.DevTunnelCreate(tunnelName, "1d", authToken, serverPort)
200200
if err != nil {
201201
ch <- err
202202
return
203203
}
204-
_, tunnelConnection, err := tunnel.DevTunnelHost(tunnelName, authToken)
205-
if err != nil {
206-
ch <- err
207-
return
208-
}
209-
if err := tunnel.DevTunnelForward(tunnelName, serverPort, authToken); err != nil {
210-
ch <- err
211-
return
212-
}
213204

214-
log.Printf("Connect to agent using the URL: %s", tunnelConnection.ConnectionURL)
215-
log.Printf("DevTunnel ID: %s", tunnelConnection.DevTunnelInfo.TunnelID)
216-
log.Printf("DevTunnel Token: %s", tunnelConnection.Token)
205+
log.Printf("Connect to agent using the URL: %s", conn.ConnectionURL)
206+
log.Printf("DevTunnel ID: %s", conn.DevTunnelInfo.TunnelID)
207+
log.Printf("DevTunnel Token: %s", conn.Token)
217208
ch <- nil
218209
}()
219210

subsystems/tunnel/api.go

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ type DevTunnelCreateRequest struct {
2424
// AuthToken is the Microsoft Entra ID (Azure AD) bearer token used to
2525
// authenticate against the Dev Tunnels service. It is required for all
2626
// devtunnel operations.
27-
AuthToken string `json:"authToken"`
27+
AuthToken string `json:"authToken"`
28+
ServerPort int `json:"serverPort"` // linkspan HTTP port to forward immediately
2829
}
2930

3031
// DevTunnelCreateResponse is the JSON body returned after a successful create+host.
3132
type DevTunnelCreateResponse struct {
32-
TunnelName string `json:"tunnelName"`
33-
TunnelID string `json:"tunnelID"`
34-
Token string `json:"token,omitempty"`
33+
TunnelName string `json:"tunnelName"`
34+
TunnelID string `json:"tunnelID"`
35+
ConnectionURL string `json:"connectionURL,omitempty"`
36+
Token string `json:"token,omitempty"`
3537
}
3638

3739
// FrpTunnelProxyCreateRequest is the JSON body for POST /tunnels/frp.
@@ -71,7 +73,8 @@ func ListDevTunnels(w http.ResponseWriter, r *http.Request) {
7173
}
7274

7375
// CreateDevTunnel handles POST /tunnels/devtunnel.
74-
// It creates the tunnel via the SDK and immediately starts hosting it via the CLI.
76+
// Creates the tunnel, hosts the relay, and forwards the server port so the
77+
// client can communicate with linkspan immediately.
7578
func CreateDevTunnel(w http.ResponseWriter, r *http.Request) {
7679
var req DevTunnelCreateRequest
7780
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -85,22 +88,17 @@ func CreateDevTunnel(w http.ResponseWriter, r *http.Request) {
8588
return
8689
}
8790

88-
info, err := DevTunnelCreate(req.TunnelName, req.Expiration, req.AuthToken)
89-
if err != nil {
90-
utils.RespondJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
91-
return
92-
}
93-
94-
_, connection, err := DevTunnelHost(req.TunnelName, req.AuthToken)
91+
conn, err := DevTunnelCreate(req.TunnelName, req.Expiration, req.AuthToken, req.ServerPort)
9592
if err != nil {
9693
utils.RespondJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
9794
return
9895
}
9996

10097
utils.RespondJSON(w, http.StatusCreated, DevTunnelCreateResponse{
101-
TunnelName: req.TunnelName,
102-
TunnelID: info.TunnelID,
103-
Token: connection.Token,
98+
TunnelName: req.TunnelName,
99+
TunnelID: conn.DevTunnelInfo.TunnelID,
100+
ConnectionURL: conn.ConnectionURL,
101+
Token: conn.Token,
104102
})
105103
}
106104

subsystems/tunnel/devtunnel.go

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ import (
88
pm "github.com/cyber-shuttle/linkspan/internal/process"
99
)
1010

11-
// DevTunnelCreate creates a tunnel on the service (no ports registered yet)
12-
// and registers it with GlobalDevTunnelManager.
13-
func DevTunnelCreate(tunnelName string, expiration string, authToken string) (DevTunnelInfo, error) {
11+
// DevTunnelCreate creates a tunnel, starts hosting the relay, and forwards the
12+
// given serverPort so the client can communicate with linkspan immediately.
13+
// Additional ports (e.g. SSH) can be added later via DevTunnelForward.
14+
func DevTunnelCreate(tunnelName string, expiration string, authToken string, serverPort int) (DevTunnelConnection, error) {
1415
if err := InitSDK(authToken); err != nil {
15-
return DevTunnelInfo{}, fmt.Errorf("devtunnel create: init SDK: %w", err)
16+
return DevTunnelConnection{}, fmt.Errorf("devtunnel create: init SDK: %w", err)
1617
}
1718

1819
ctx := context.Background()
20+
21+
// 1. Create the tunnel on the service.
1922
sdkTunnel, err := SDKCreateTunnel(ctx, tunnelName)
2023
if err != nil {
21-
return DevTunnelInfo{}, fmt.Errorf("devtunnel create %q: %w", tunnelName, err)
24+
return DevTunnelConnection{}, fmt.Errorf("devtunnel create %q: %w", tunnelName, err)
2225
}
2326

2427
info := &DevTunnelInfo{
@@ -32,52 +35,43 @@ func DevTunnelCreate(tunnelName string, expiration string, authToken string) (De
3235
log.Printf("devtunnel create: warning — failed to register %q in manager: %v", tunnelName, err)
3336
}
3437

35-
log.Printf("devtunnel create: tunnel %q ready (id=%s)", tunnelName, sdkTunnel.TunnelID)
36-
return *info, nil
37-
}
38-
39-
// DevTunnelHost starts hosting the tunnel relay connection.
40-
// No ports are forwarded yet — use DevTunnelForward to add port forwarding.
41-
func DevTunnelHost(tunnelName string, authToken string) (string, DevTunnelConnection, error) {
42-
if err := InitSDK(authToken); err != nil {
43-
return "", DevTunnelConnection{}, fmt.Errorf("devtunnel host: init SDK: %w", err)
44-
}
45-
46-
devTunInfo, err := GlobalDevTunnelManager.Find(tunnelName)
47-
if err != nil {
48-
return "", DevTunnelConnection{}, fmt.Errorf("devtunnel host: tunnel %q not registered: %w", tunnelName, err)
38+
// 2. Register the server port so it is forwarded through the tunnel.
39+
if serverPort > 0 {
40+
if err := SDKAddPort(ctx, tunnelName, serverPort); err != nil {
41+
return DevTunnelConnection{}, fmt.Errorf("devtunnel create: add server port %d to %q: %w", serverPort, tunnelName, err)
42+
}
43+
info.Ports = append(info.Ports, serverPort)
4944
}
5045

51-
ctx := context.Background()
52-
46+
// 3. Obtain host token and start the relay with the server port forwarded.
5347
hostToken, err := SDKGetHostToken(ctx, tunnelName)
5448
if err != nil {
55-
return "", DevTunnelConnection{}, fmt.Errorf("devtunnel host: get host token for %q: %w", tunnelName, err)
49+
return DevTunnelConnection{}, fmt.Errorf("devtunnel create: get host token for %q: %w", tunnelName, err)
5650
}
5751

58-
// Host without ports — all port forwarding is done later via DevTunnelForward.
59-
cmdID, connectionURL, err := CLIHostTunnel(devTunInfo.TunnelID, hostToken, nil)
52+
cmdID, connectionURL, err := CLIHostTunnel(info.TunnelID, hostToken, info.Ports)
6053
if err != nil {
61-
return "", DevTunnelConnection{}, fmt.Errorf("devtunnel host: start CLI for %q: %w", tunnelName, err)
54+
return DevTunnelConnection{}, fmt.Errorf("devtunnel create: start host for %q: %w", tunnelName, err)
6255
}
6356

64-
// Track the host process command ID so DevTunnelForward can restart it.
65-
devTunInfo.HostCmdID = cmdID
66-
devTunInfo.HostToken = hostToken
57+
info.HostCmdID = cmdID
58+
info.HostToken = hostToken
6759

6860
conn := DevTunnelConnection{
6961
ConnectionURL: connectionURL,
70-
DevTunnelInfo: devTunInfo,
62+
DevTunnelInfo: info,
7163
}
7264

65+
// 4. Get a connect token for the client side.
7366
connectToken, tokenErr := SDKGetConnectToken(ctx, tunnelName)
7467
if tokenErr != nil {
75-
log.Printf("devtunnel host: warning — could not obtain connect token for %q: %v", tunnelName, tokenErr)
68+
log.Printf("devtunnel create: warning — could not obtain connect token for %q: %v", tunnelName, tokenErr)
7669
} else {
7770
conn.Token = connectToken
7871
}
7972

80-
return cmdID, conn, nil
73+
log.Printf("devtunnel create: tunnel %q ready (id=%s, url=%s, port=%d)", tunnelName, sdkTunnel.TunnelID, connectionURL, serverPort)
74+
return conn, nil
8175
}
8276

8377
// DevTunnelForward adds port forwarding to an existing hosted tunnel.
@@ -116,7 +110,6 @@ func DevTunnelForward(tunnelName string, port int, authToken string) error {
116110

117111
hostToken := devTunInfo.HostToken
118112
if hostToken == "" {
119-
// Re-fetch if not cached (shouldn't happen in normal flow).
120113
hostToken, err = SDKGetHostToken(ctx, tunnelName)
121114
if err != nil {
122115
return fmt.Errorf("devtunnel forward: get host token for %q: %w", tunnelName, err)

subsystems/tunnel/devtunnel_test.go

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func authTokenForTest(t *testing.T) string {
1919
return token
2020
}
2121

22-
func TestDevTunnelConnect(t *testing.T) {
22+
func TestDevTunnelCreateAndHost(t *testing.T) {
2323
authToken := authTokenForTest(t)
2424
tunnelName := "test-tunnel"
2525

@@ -30,42 +30,40 @@ func TestDevTunnelConnect(t *testing.T) {
3030
}
3131
}()
3232

33-
_, err := DevTunnelCreate(tunnelName, "1d", authToken)
33+
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 8080)
3434
if err != nil {
3535
t.Fatalf("failed to create dev tunnel: %v", err)
36-
} else {
37-
t.Logf("dev tunnel created successfully")
3836
}
37+
t.Logf("dev tunnel created+hosted: url=%s token=%s", conn.ConnectionURL, conn.Token)
3938

40-
tunnelCommandId, tunnelConnection, err := DevTunnelHost(tunnelName, authToken)
41-
if err != nil {
42-
t.Fatalf("failed to set up dev tunnel: %v", err)
43-
} else {
44-
t.Logf("dev tunnel set up successfully: %+v", tunnelConnection)
39+
if conn.DevTunnelInfo.HostCmdID == "" {
40+
t.Fatal("expected host command ID to be set")
4541
}
4642

47-
err = pm.GlobalProcessManager.Kill(tunnelCommandId)
43+
err = pm.GlobalProcessManager.Kill(conn.DevTunnelInfo.HostCmdID)
4844
if err != nil {
49-
t.Fatalf("failed to stop dev tunnel command with id %s: %v", tunnelCommandId, err)
50-
} else {
51-
t.Logf("dev tunnel command with id %s stopped successfully", tunnelCommandId)
45+
t.Fatalf("failed to stop dev tunnel host: %v", err)
5246
}
47+
t.Logf("dev tunnel host stopped successfully")
5348
}
5449

55-
func TestDevTunnelCreate(t *testing.T) {
50+
func TestDevTunnelCreateNoPort(t *testing.T) {
5651
authToken := authTokenForTest(t)
52+
tunnelName := "test-tunnel-noport"
5753

58-
_, err := DevTunnelCreate("test-tunnel", "1d", authToken)
54+
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 0)
5955
if err != nil {
6056
t.Fatalf("failed to create dev tunnel: %v", err)
61-
} else {
62-
t.Logf("dev tunnel created successfully")
57+
}
58+
t.Logf("dev tunnel created: url=%s", conn.ConnectionURL)
59+
60+
if conn.DevTunnelInfo.HostCmdID != "" {
61+
_ = pm.GlobalProcessManager.Kill(conn.DevTunnelInfo.HostCmdID)
6362
}
6463

65-
err = DevTunnelDelete("test-tunnel", authToken)
64+
err = DevTunnelDelete(tunnelName, authToken)
6665
if err != nil {
6766
t.Fatalf("failed to delete dev tunnel: %v", err)
68-
} else {
69-
t.Logf("dev tunnel deleted successfully")
7067
}
68+
t.Logf("dev tunnel deleted successfully")
7169
}

0 commit comments

Comments
 (0)