Skip to content

Commit 5804e4d

Browse files
Bastion component
1 parent b4ede50 commit 5804e4d

File tree

12 files changed

+1142
-75
lines changed

12 files changed

+1142
-75
lines changed

cmd/sst/tunnel.go

Lines changed: 138 additions & 50 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`",
@@ -39,7 +41,7 @@ var CmdTunnel = &cli.Command{
3941
"The tunnel allows your local machine to access resources that are in the VPC.",
4042
"",
4143
":::note",
42-
"The tunnel is only available for apps that have a VPC with `bastion` enabled.",
44+
"The tunnel is only available for apps that have a VPC with `bastion` enabled, or apps that have a Bastion component",
4345
":::",
4446
"",
4547
"If you are running `sst dev`, this tunnel will be started automatically under the",
@@ -57,6 +59,11 @@ 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
},
6269
Run: func(c *cli.Cli) error {
@@ -104,41 +111,117 @@ var CmdTunnel = &cli.Command{
104111
if len(completed.Tunnels) == 0 {
105112
return util.NewReadableError(nil, "No tunnels found for stage "+stage)
106113
}
107-
var tun project.Tunnel
108-
for _, item := range completed.Tunnels {
109-
tun = item
114+
115+
var ssmConfigs []tunnel.SSMConfig
116+
var sshConfigs []tunnel.SSHConfig
117+
var allSubnets []string
118+
119+
for name, tun := range completed.Tunnels {
120+
mode := tun.Mode
121+
122+
// backwards compatible for vpc bastion v1
123+
if mode == "" {
124+
mode = "ssh"
125+
}
126+
127+
if mode == "ssm" {
128+
if tun.InstanceID == "" {
129+
slog.Warn("SSM tunnel missing instance ID, skipping", "name", name)
130+
continue
131+
}
132+
if tun.Region == "" {
133+
slog.Warn("SSM tunnel missing region, skipping", "name", name)
134+
continue
135+
}
136+
ssmConfigs = append(ssmConfigs, tunnel.SSMConfig{
137+
InstanceID: tun.InstanceID,
138+
Region: tun.Region,
139+
Subnets: tun.Subnets,
140+
})
141+
} else if mode == "ssh" {
142+
if tun.IP == "" && tun.PrivateKey == "" {
143+
slog.Warn("SSH tunnel missing IP, skipping", "name", name)
144+
continue
145+
}
146+
sshConfigs = append(sshConfigs, tunnel.SSHConfig{
147+
Host: tun.IP,
148+
Username: tun.Username,
149+
PrivateKey: tun.PrivateKey,
150+
Subnets: tun.Subnets,
151+
})
152+
}
153+
154+
allSubnets = append(allSubnets, tun.Subnets...)
155+
}
156+
157+
if len(ssmConfigs) == 0 && len(sshConfigs) == 0 {
158+
return util.NewReadableError(nil, "No tunnels found. Make sure you have a bastion deployed.")
110159
}
111-
subnets := strings.Join(tun.Subnets, ",")
160+
161+
if len(ssmConfigs) > 0 {
162+
if _, err := exec.LookPath("session-manager-plugin"); err != nil {
163+
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 `ssm: false` on your Bastion component.")
164+
}
165+
}
166+
167+
args := []string{
168+
"-n", "-E",
169+
tunnel.BINARY_PATH, "tunnel", "start",
170+
"--subnets", strings.Join(allSubnets, ","),
171+
"--print-logs",
172+
}
173+
174+
if len(ssmConfigs) > 0 {
175+
ssmJSON, err := json.Marshal(ssmConfigs)
176+
if err != nil {
177+
return fmt.Errorf("failed to serialize SSM config: %w", err)
178+
}
179+
args = append(args, "--ssm-config", string(ssmJSON))
180+
}
181+
182+
if len(sshConfigs) > 0 {
183+
sshJSON, err := json.Marshal(sshConfigs)
184+
if err != nil {
185+
return fmt.Errorf("failed to serialize SSH config: %w", err)
186+
}
187+
args = append(args, "--ssh-config", string(sshJSON))
188+
}
189+
112190
// run as root
113191
tunnelCmd := process.CommandContext(
114192
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",
193+
"sudo",
194+
args...,
121195
)
122196
tunnelCmd.Env = append(
123197
os.Environ(),
124198
"SST_SKIP_LOCAL=true",
125199
"SST_SKIP_DEPENDENCY_CHECK=true",
126-
"SSH_PRIVATE_KEY="+tun.PrivateKey,
127200
"SST_LOG="+strings.ReplaceAll(os.Getenv("SST_LOG"), ".log", "_sudo.log"),
128201
)
129202
tunnelCmd.Stdout = os.Stdout
130203
slog.Info("starting tunnel", "cmd", tunnelCmd.Args)
131204
fmt.Println(ui.TEXT_HIGHLIGHT_BOLD.Render("Tunnel"))
132205
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))
206+
207+
for _, cfg := range ssmConfigs {
208+
fmt.Print(ui.TEXT_HIGHLIGHT_BOLD.Render("▤"))
209+
fmt.Println(ui.TEXT_NORMAL.Render(" " + cfg.InstanceID + " (SSM, " + cfg.Region + ")"))
210+
for _, subnet := range cfg.Subnets {
211+
fmt.Println(ui.TEXT_DIM.Render(" " + subnet))
212+
}
213+
fmt.Println()
140214
}
141-
fmt.Println()
215+
216+
for _, cfg := range sshConfigs {
217+
fmt.Print(ui.TEXT_HIGHLIGHT_BOLD.Render("▤"))
218+
fmt.Println(ui.TEXT_NORMAL.Render(" " + cfg.Host + " (SSH)"))
219+
for _, subnet := range cfg.Subnets {
220+
fmt.Println(ui.TEXT_DIM.Render(" " + subnet))
221+
}
222+
fmt.Println()
223+
}
224+
142225
fmt.Println(ui.TEXT_DIM.Render("Waiting for connections..."))
143226
fmt.Println()
144227
stderr, _ := tunnelCmd.StderrPipe()
@@ -201,56 +284,61 @@ var CmdTunnel = &cli.Command{
201284
Name: "subnets",
202285
Type: "string",
203286
Description: cli.Description{
204-
Short: "The subnet to use for the tunnel",
205-
Long: "The subnet to use for the tunnel",
206-
},
207-
},
208-
{
209-
Name: "host",
210-
Type: "string",
211-
Description: cli.Description{
212-
Short: "The host to use for the tunnel",
213-
Long: "The host to use for the tunnel",
287+
Short: "The subnets to route through the tunnel",
288+
Long: "The subnets to route through the tunnel",
214289
},
215290
},
216291
{
217-
Name: "port",
292+
Name: "ssm-config",
218293
Type: "string",
219294
Description: cli.Description{
220-
Short: "The port to use for the tunnel",
221-
Long: "The port to use for the tunnel",
295+
Short: "JSON-encoded SSM tunnel configurations",
296+
Long: "JSON-encoded SSM tunnel configurations",
222297
},
223298
},
224299
{
225-
Name: "user",
300+
Name: "ssh-config",
226301
Type: "string",
227302
Description: cli.Description{
228-
Short: "The user to use for the tunnel",
229-
Long: "The user to use for the tunnel",
303+
Short: "JSON-encoded SSH tunnel configurations",
304+
Long: "JSON-encoded SSH tunnel configurations",
230305
},
231306
},
232307
},
233308
Run: func(c *cli.Cli) error {
234309
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"
310+
ssmJSON := c.String("ssm-config")
311+
sshJSON := c.String("ssh-config")
312+
313+
var ssmConfigs []tunnel.SSMConfig
314+
var sshConfigs []tunnel.SSHConfig
315+
316+
if ssmJSON != "" {
317+
if err := json.Unmarshal([]byte(ssmJSON), &ssmConfigs); err != nil {
318+
return util.NewReadableError(err, "failed to parse SSM configuration")
319+
}
320+
}
321+
322+
if sshJSON != "" {
323+
if err := json.Unmarshal([]byte(sshJSON), &sshConfigs); err != nil {
324+
return util.NewReadableError(err, "failed to parse SSH configuration")
325+
}
240326
}
241-
slog.Info("starting tunnel", "subnet", subnets, "host", host, "port", port)
327+
328+
if len(ssmConfigs) == 0 && len(sshConfigs) == 0 {
329+
return util.NewReadableError(nil, "at least one SSM or SSH tunnel is required")
330+
}
331+
332+
slog.Info("starting tunnel", "subnets", subnets, "ssm", len(ssmConfigs), "ssh", len(sshConfigs))
242333
err := tunnel.Start(subnets...)
334+
defer tunnel.Stop()
243335
if err != nil {
244336
return err
245337
}
246-
defer tunnel.Stop()
247338
slog.Info("tunnel started")
248-
err = tunnel.StartProxy(
249-
c.Context,
250-
user,
251-
host+":"+port,
252-
[]byte(os.Getenv("SSH_PRIVATE_KEY")),
253-
)
339+
340+
err = tunnel.StartProxy(c.Context, ssmConfigs, sshConfigs)
341+
254342
if err != nil {
255343
slog.Error("failed to start tunnel", "error", err)
256344
}

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)