Skip to content

Commit eb3ef85

Browse files
Bastion component
1 parent b4ede50 commit eb3ef85

File tree

13 files changed

+1216
-120
lines changed

13 files changed

+1216
-120
lines changed

cmd/sst/tunnel.go

Lines changed: 157 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"io"
67
"log/slog"
78
"os"
9+
"os/exec"
810
"os/user"
911
"strings"
1012

@@ -30,7 +32,7 @@ var CmdTunnel = &cli.Command{
3032
"```",
3133
"",
3234
"If your app has a VPC with `bastion` enabled, you can use this to connect to it.",
33-
"This will forward traffic from the following ranges over SSH:",
35+
"This will forward traffic from the following ranges using either SSH or SSM, depending on your bastion configuration:",
3436
"- `10.0.4.0/22`",
3537
"- `10.0.12.0/22`",
3638
"- `10.0.0.0/22`",
@@ -57,8 +59,23 @@ var CmdTunnel = &cli.Command{
5759
"",
5860
"This needs a network interface on your local machine. You can create this",
5961
"with the `sst tunnel install` command.",
62+
"",
63+
":::note",
64+
"When using the Bastion component in SSM mode, the tunnel requires the AWS Session Manager Plugin to be installed.",
65+
"https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html",
66+
":::",
6067
}, "\n"),
6168
},
69+
Flags: []cli.Flag{
70+
{
71+
Name: "ssm",
72+
Type: "bool",
73+
Description: cli.Description{
74+
Short: "Use SSM mode instead of SSH",
75+
Long: "Use SSM mode instead of SSH. This requires the AWS Session Manager Plugin to be installed.",
76+
},
77+
},
78+
},
6279
Run: func(c *cli.Cli) error {
6380
if tunnel.NeedsInstall() {
6481
return util.NewReadableError(nil, "The sst tunnel needs to be installed or upgraded. Run `sudo sst tunnel install`")
@@ -104,41 +121,127 @@ var CmdTunnel = &cli.Command{
104121
if len(completed.Tunnels) == 0 {
105122
return util.NewReadableError(nil, "No tunnels found for stage "+stage)
106123
}
107-
var tun project.Tunnel
108-
for _, item := range completed.Tunnels {
109-
tun = item
124+
125+
forceSSM := c.Bool("ssm")
126+
127+
var ssmConfigs []tunnel.SSMConfig
128+
var sshConfigs []tunnel.SSHConfig
129+
var allSubnets []string
130+
131+
for name, tun := range completed.Tunnels {
132+
mode := tun.Mode
133+
134+
if forceSSM && tun.InstanceID != "" {
135+
mode = "ssm"
136+
} else if mode == "" || mode == "ssh" {
137+
if tun.IP != "" && tun.PrivateKey != "" {
138+
mode = "ssh"
139+
} else if tun.InstanceID != "" {
140+
mode = "ssm"
141+
} else {
142+
slog.Warn("tunnel missing required fields, skipping", "name", name)
143+
continue
144+
}
145+
}
146+
147+
if mode == "ssm" {
148+
if tun.InstanceID == "" {
149+
slog.Warn("SSM tunnel missing instance ID, skipping", "name", name)
150+
continue
151+
}
152+
if tun.Region == "" {
153+
slog.Warn("SSM tunnel missing region, skipping", "name", name)
154+
continue
155+
}
156+
ssmConfigs = append(ssmConfigs, tunnel.SSMConfig{
157+
InstanceID: tun.InstanceID,
158+
Region: tun.Region,
159+
Subnets: tun.Subnets,
160+
})
161+
} else if mode == "ssh" {
162+
if tun.IP == "" {
163+
slog.Warn("SSH tunnel missing IP, skipping", "name", name)
164+
continue
165+
}
166+
sshConfigs = append(sshConfigs, tunnel.SSHConfig{
167+
Host: tun.IP,
168+
Username: tun.Username,
169+
PrivateKey: tun.PrivateKey,
170+
Subnets: tun.Subnets,
171+
})
172+
}
173+
174+
allSubnets = append(allSubnets, tun.Subnets...)
175+
}
176+
177+
if len(ssmConfigs) == 0 && len(sshConfigs) == 0 {
178+
return util.NewReadableError(nil, "No tunnels found. Make sure you have a bastion deployed.")
179+
}
180+
181+
if len(ssmConfigs) > 0 {
182+
if _, err := exec.LookPath("session-manager-plugin"); err != nil {
183+
return util.NewReadableError(nil, "AWS Session Manager Plugin is required for SSM tunnels but was not found.\n\nInstall it from: https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html\n\nAlternatively, you can use SSH mode by setting `ssh: true` on your Bastion component.")
184+
}
185+
}
186+
187+
args := []string{
188+
"-n", "-E",
189+
tunnel.BINARY_PATH, "tunnel", "start",
190+
"--subnets", strings.Join(allSubnets, ","),
191+
"--print-logs",
192+
}
193+
194+
if len(ssmConfigs) > 0 {
195+
ssmJSON, err := json.Marshal(ssmConfigs)
196+
if err != nil {
197+
return fmt.Errorf("failed to serialize SSM config: %w", err)
198+
}
199+
args = append(args, "--ssm-config", string(ssmJSON))
200+
}
201+
202+
if len(sshConfigs) > 0 {
203+
sshJSON, err := json.Marshal(sshConfigs)
204+
if err != nil {
205+
return fmt.Errorf("failed to serialize SSH config: %w", err)
206+
}
207+
args = append(args, "--ssh-config", string(sshJSON))
110208
}
111-
subnets := strings.Join(tun.Subnets, ",")
209+
112210
// run as root
113211
tunnelCmd := process.CommandContext(
114212
c.Context,
115-
"sudo", "-n", "-E",
116-
tunnel.BINARY_PATH, "tunnel", "start",
117-
"--subnets", subnets,
118-
"--host", tun.IP,
119-
"--user", tun.Username,
120-
"--print-logs",
213+
"sudo",
214+
args...,
121215
)
122216
tunnelCmd.Env = append(
123217
os.Environ(),
124218
"SST_SKIP_LOCAL=true",
125219
"SST_SKIP_DEPENDENCY_CHECK=true",
126-
"SSH_PRIVATE_KEY="+tun.PrivateKey,
127220
"SST_LOG="+strings.ReplaceAll(os.Getenv("SST_LOG"), ".log", "_sudo.log"),
128221
)
129222
tunnelCmd.Stdout = os.Stdout
130223
slog.Info("starting tunnel", "cmd", tunnelCmd.Args)
131224
fmt.Println(ui.TEXT_HIGHLIGHT_BOLD.Render("Tunnel"))
132225
fmt.Println()
133-
fmt.Print(ui.TEXT_HIGHLIGHT_BOLD.Render("▤"))
134-
fmt.Println(ui.TEXT_NORMAL.Render(" " + tun.IP))
135-
fmt.Println()
136-
fmt.Print(ui.TEXT_SUCCESS_BOLD.Render("➜"))
137-
fmt.Println(ui.TEXT_NORMAL.Render(" Ranges"))
138-
for _, subnet := range tun.Subnets {
139-
fmt.Println(ui.TEXT_DIM.Render(" " + subnet))
226+
227+
for _, cfg := range ssmConfigs {
228+
fmt.Print(ui.TEXT_HIGHLIGHT_BOLD.Render("▤"))
229+
fmt.Println(ui.TEXT_NORMAL.Render(" " + cfg.InstanceID + " (SSM, " + cfg.Region + ")"))
230+
for _, subnet := range cfg.Subnets {
231+
fmt.Println(ui.TEXT_DIM.Render(" " + subnet))
232+
}
233+
fmt.Println()
140234
}
141-
fmt.Println()
235+
236+
for _, cfg := range sshConfigs {
237+
fmt.Print(ui.TEXT_HIGHLIGHT_BOLD.Render("▤"))
238+
fmt.Println(ui.TEXT_NORMAL.Render(" " + cfg.Host + " (SSH)"))
239+
for _, subnet := range cfg.Subnets {
240+
fmt.Println(ui.TEXT_DIM.Render(" " + subnet))
241+
}
242+
fmt.Println()
243+
}
244+
142245
fmt.Println(ui.TEXT_DIM.Render("Waiting for connections..."))
143246
fmt.Println()
144247
stderr, _ := tunnelCmd.StderrPipe()
@@ -201,56 +304,61 @@ var CmdTunnel = &cli.Command{
201304
Name: "subnets",
202305
Type: "string",
203306
Description: cli.Description{
204-
Short: "The subnet to use for the tunnel",
205-
Long: "The subnet to use for the tunnel",
307+
Short: "The subnets to route through the tunnel",
308+
Long: "The subnets to route through the tunnel",
206309
},
207310
},
208311
{
209-
Name: "host",
312+
Name: "ssm-config",
210313
Type: "string",
211314
Description: cli.Description{
212-
Short: "The host to use for the tunnel",
213-
Long: "The host to use for the tunnel",
315+
Short: "JSON-encoded SSM tunnel configurations",
316+
Long: "JSON-encoded SSM tunnel configurations",
214317
},
215318
},
216319
{
217-
Name: "port",
320+
Name: "ssh-config",
218321
Type: "string",
219322
Description: cli.Description{
220-
Short: "The port to use for the tunnel",
221-
Long: "The port to use for the tunnel",
222-
},
223-
},
224-
{
225-
Name: "user",
226-
Type: "string",
227-
Description: cli.Description{
228-
Short: "The user to use for the tunnel",
229-
Long: "The user to use for the tunnel",
323+
Short: "JSON-encoded SSH tunnel configurations",
324+
Long: "JSON-encoded SSH tunnel configurations",
230325
},
231326
},
232327
},
233328
Run: func(c *cli.Cli) error {
234329
subnets := strings.Split(c.String("subnets"), ",")
235-
host := c.String("host")
236-
port := c.String("port")
237-
user := c.String("user")
238-
if port == "" {
239-
port = "22"
330+
ssmJSON := c.String("ssm-config")
331+
sshJSON := c.String("ssh-config")
332+
333+
var ssmConfigs []tunnel.SSMConfig
334+
var sshConfigs []tunnel.SSHConfig
335+
336+
if ssmJSON != "" {
337+
if err := json.Unmarshal([]byte(ssmJSON), &ssmConfigs); err != nil {
338+
return util.NewReadableError(err, "failed to parse SSM configuration")
339+
}
340+
}
341+
342+
if sshJSON != "" {
343+
if err := json.Unmarshal([]byte(sshJSON), &sshConfigs); err != nil {
344+
return util.NewReadableError(err, "failed to parse SSH configuration")
345+
}
346+
}
347+
348+
if len(ssmConfigs) == 0 && len(sshConfigs) == 0 {
349+
return util.NewReadableError(nil, "at least one SSM or SSH tunnel is required")
240350
}
241-
slog.Info("starting tunnel", "subnet", subnets, "host", host, "port", port)
351+
352+
slog.Info("starting tunnel", "subnets", subnets, "ssm", len(ssmConfigs), "ssh", len(sshConfigs))
242353
err := tunnel.Start(subnets...)
354+
defer tunnel.Stop()
243355
if err != nil {
244356
return err
245357
}
246-
defer tunnel.Stop()
247358
slog.Info("tunnel started")
248-
err = tunnel.StartProxy(
249-
c.Context,
250-
user,
251-
host+":"+port,
252-
[]byte(os.Getenv("SSH_PRIVATE_KEY")),
253-
)
359+
360+
err = tunnel.StartProxy(c.Context, ssmConfigs, sshConfigs)
361+
254362
if err != nil {
255363
slog.Error("failed to start tunnel", "error", err)
256364
}

pkg/project/completed.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,32 @@ func getCompletedEvent(ctx context.Context, passphrase string, workdir *PulumiWo
9696
}
9797

9898
if match, ok := outputs["_tunnel"].(map[string]interface{}); ok {
99-
ip, ipOk := match["ip"].(string)
100-
username, usernameOk := match["username"].(string)
101-
privateKey, privateKeyOk := match["privateKey"].(string)
102-
if !ipOk || !usernameOk || !privateKeyOk {
103-
continue
99+
mode, _ := match["mode"].(string)
100+
if mode == "" {
101+
mode = "ssh"
104102
}
103+
105104
tunnel := Tunnel{
106-
IP: ip,
107-
Username: username,
108-
PrivateKey: privateKey,
109-
Subnets: []string{},
105+
Mode: mode,
106+
Subnets: []string{},
107+
}
108+
109+
if ip, ok := match["ip"].(string); ok {
110+
tunnel.IP = ip
111+
}
112+
if username, ok := match["username"].(string); ok {
113+
tunnel.Username = username
110114
}
115+
if privateKey, ok := match["privateKey"].(string); ok {
116+
tunnel.PrivateKey = privateKey
117+
}
118+
if instanceId, ok := match["instanceId"].(string); ok {
119+
tunnel.InstanceID = instanceId
120+
}
121+
if region, ok := match["region"].(string); ok {
122+
tunnel.Region = region
123+
}
124+
111125
if subnets, ok := match["subnets"].([]interface{}); ok {
112126
for _, subnet := range subnets {
113127
if s, ok := subnet.(string); ok {

pkg/project/stack.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type Tunnel struct {
8686
Username string `json:"username"`
8787
PrivateKey string `json:"privateKey"`
8888
Subnets []string `json:"subnets"`
89+
InstanceID string `json:"instanceId"`
90+
Region string `json:"region"`
91+
Mode string `json:"mode"` // "ssh" or "ssm"
8992
}
9093

9194
type ImportDiff struct {

0 commit comments

Comments
 (0)