Skip to content

Commit 5b746f7

Browse files
committed
feat(ssh context credentials in keychain)
1 parent 22e16bf commit 5b746f7

10 files changed

Lines changed: 491 additions & 37 deletions

File tree

cmd/d4s/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/jr-k/d4s/internal/buildinfo"
1515
"github.com/jr-k/d4s/internal/config"
16+
"github.com/jr-k/d4s/internal/secrets"
1617
"github.com/jr-k/d4s/internal/ui"
1718
"github.com/jr-k/d4s/internal/ui/common"
1819
)
@@ -43,6 +44,11 @@ func printColored(format string, a ...interface{}) {
4344
}
4445

4546
func main() {
47+
// SSH_ASKPASS mode: ssh invokes d4s to retrieve stored credentials
48+
if secrets.RunAskpassIfRequested() {
49+
return
50+
}
51+
4652
// Version flags
4753
showVersion := flag.Bool("version", false, "Print version and exit")
4854
flag.BoolVar(showVersion, "v", false, "Print version and exit (shorthand)")

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ require (
1111
github.com/guptarohit/asciigraph v0.7.3
1212
github.com/lucasb-eyer/go-colorful v1.3.0
1313
github.com/rivo/tview v0.42.0
14+
github.com/zalando/go-keyring v0.2.8
15+
golang.org/x/crypto v0.53.0
1416
golang.org/x/net v0.55.0
1517
gopkg.in/yaml.v3 v3.0.1
1618
)
@@ -21,6 +23,7 @@ require (
2123
github.com/containerd/errdefs v1.0.0 // indirect
2224
github.com/containerd/errdefs/pkg v0.3.0 // indirect
2325
github.com/containerd/log v0.1.0 // indirect
26+
github.com/danieljoos/wincred v1.2.3 // indirect
2427
github.com/distribution/reference v0.6.0 // indirect
2528
github.com/dlclark/regexp2 v1.11.5 // indirect
2629
github.com/docker/docker-credential-helpers v0.9.5 // indirect
@@ -31,6 +34,7 @@ require (
3134
github.com/gdamore/encoding v1.0.1 // indirect
3235
github.com/go-logr/logr v1.4.3 // indirect
3336
github.com/go-logr/stdr v1.2.2 // indirect
37+
github.com/godbus/dbus/v5 v5.2.2 // indirect
3438
github.com/moby/docker-image-spec v1.3.1 // indirect
3539
github.com/moby/moby/api v1.52.0 // indirect
3640
github.com/moby/moby/client v0.2.1 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
2020
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
2121
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
2222
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
23+
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
24+
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
2325
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2426
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2527
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -50,6 +52,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
5052
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
5153
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
5254
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
55+
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
56+
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
5357
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
5458
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5559
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -97,10 +101,14 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
97101
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
98102
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
99103
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
104+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
105+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
100106
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
101107
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
102108
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
103109
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
110+
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
111+
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
104112
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
105113
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
106114
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
@@ -123,6 +131,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
123131
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
124132
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
125133
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
134+
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
135+
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
126136
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
127137
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
128138
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

internal/dao/docker.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/jr-k/d4s/internal/dao/docker/volume"
3434
"github.com/jr-k/d4s/internal/dao/swarm/node"
3535
"github.com/jr-k/d4s/internal/dao/swarm/service"
36+
"github.com/jr-k/d4s/internal/secrets"
3637
"github.com/jr-k/d4s/internal/dao/swarm/task"
3738
)
3839

@@ -244,7 +245,21 @@ func loadSpecificContext(targetCtx string, logger *log.Logger, baseOpts []client
244245
}
245246

246247
logger.Printf("Using Host: %s", ep.Host)
247-
helper, err := connhelper.GetConnectionHelper(ep.Host)
248+
249+
var helper *connhelper.ConnectionHelper
250+
if strings.HasPrefix(ep.Host, "ssh://") {
251+
creds, _ := secrets.Load(targetCtx)
252+
if creds.HasSecret() {
253+
// Spawned ssh processes (docker dial-stdio, tunnels) will query
254+
// d4s itself via SSH_ASKPASS to obtain the stored secret.
255+
secrets.ApplyAskpassEnv(targetCtx)
256+
} else {
257+
secrets.ApplyAskpassEnv("")
258+
}
259+
helper, err = connhelper.GetConnectionHelperWithSSHOpts(ep.Host, creds.SSHArgs())
260+
} else {
261+
helper, err = connhelper.GetConnectionHelper(ep.Host)
262+
}
248263
if err != nil {
249264
return nil, err
250265
}
@@ -617,8 +632,13 @@ func (d *DockerClient) InspectContext(name string) (string, error) {
617632
return string(output), nil
618633
}
619634

620-
func (d *DockerClient) RemoveContext(name string) error {
621-
cmd := exec.Command("docker", "context", "rm", name)
635+
func (d *DockerClient) RemoveContext(name string, force bool) error {
636+
args := []string{"context", "rm"}
637+
if force {
638+
args = append(args, "-f")
639+
}
640+
args = append(args, name)
641+
cmd := exec.Command("docker", args...)
622642
output, err := cmd.CombinedOutput()
623643
if err != nil {
624644
return fmt.Errorf("error removing context: %v, output: %s", err, string(output))

internal/portforward/manager.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (m *Manager) Add(pf *PortForward) error {
107107
m.mu.Lock()
108108
defer m.mu.Unlock()
109109

110-
tunnel, err := NewTunnel(pf.SSHHost, pf.LocalPort, pf.ContainerID, pf.ContainerPort, pf.HostPort)
110+
tunnel, err := NewTunnel(pf.ContextName, pf.SSHHost, pf.LocalPort, pf.ContainerID, pf.ContainerPort, pf.HostPort)
111111
if err != nil {
112112
return fmt.Errorf("tunnel creation failed: %w", err)
113113
}
@@ -142,7 +142,7 @@ func (m *Manager) Start(id string) error {
142142
return fmt.Errorf("port-forward %s not found", id)
143143
}
144144

145-
tunnel, err := NewTunnel(pf.SSHHost, pf.LocalPort, pf.ContainerID, pf.ContainerPort, pf.HostPort)
145+
tunnel, err := NewTunnel(pf.ContextName, pf.SSHHost, pf.LocalPort, pf.ContainerID, pf.ContainerPort, pf.HostPort)
146146
if err != nil {
147147
return fmt.Errorf("tunnel creation failed: %w", err)
148148
}

internal/portforward/tunnel.go

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,58 @@ import (
44
"bytes"
55
"fmt"
66
"net"
7+
"os"
78
"os/exec"
89
"strings"
910
"sync"
1011
"time"
12+
13+
"github.com/jr-k/d4s/internal/secrets"
1114
)
1215

1316
const socatImage = "alpine/socat"
1417

18+
// sshAuth holds per-context ssh authentication settings resolved
19+
// from the OS keychain.
20+
type sshAuth struct {
21+
extraArgs []string
22+
env []string
23+
batchMode bool
24+
}
25+
26+
func resolveSSHAuth(contextName string) sshAuth {
27+
auth := sshAuth{batchMode: true}
28+
creds, err := secrets.Load(contextName)
29+
if err != nil || creds == nil {
30+
return auth
31+
}
32+
auth.extraArgs = creds.SSHArgs()
33+
if creds.HasSecret() {
34+
// BatchMode disables askpass, so it must be off when a stored
35+
// secret has to be served through SSH_ASKPASS.
36+
auth.batchMode = false
37+
auth.env = append(os.Environ(), secrets.AskpassEnv(contextName)...)
38+
}
39+
return auth
40+
}
41+
42+
func (a sshAuth) baseArgs() []string {
43+
args := []string{
44+
"-o", "StrictHostKeyChecking=accept-new",
45+
"-o", "ConnectTimeout=10",
46+
}
47+
if a.batchMode {
48+
args = append(args, "-o", "BatchMode=yes")
49+
}
50+
return append(args, a.extraArgs...)
51+
}
52+
53+
func (a sshAuth) apply(cmd *exec.Cmd) {
54+
if a.env != nil {
55+
cmd.Env = a.env
56+
}
57+
}
58+
1559
type Tunnel struct {
1660
// direct mode (ssh -N -L): persistent ssh process
1761
cmd *exec.Cmd
@@ -32,14 +76,15 @@ type Tunnel struct {
3276
// plain ssh -L tunnel to 127.0.0.1:hostPort is used.
3377
// Otherwise (overlay networks, unpublished ports), each connection is piped
3478
// through a socat process running inside the container's network namespace.
35-
func NewTunnel(sshHost string, localPort uint16, containerID string, containerPort, hostPort uint16) (*Tunnel, error) {
79+
func NewTunnel(contextName, sshHost string, localPort uint16, containerID string, containerPort, hostPort uint16) (*Tunnel, error) {
80+
auth := resolveSSHAuth(contextName)
3681
if hostPort > 0 {
37-
return newDirectTunnel(sshHost, localPort, hostPort)
82+
return newDirectTunnel(auth, sshHost, localPort, hostPort)
3883
}
39-
return newNetnsTunnel(sshHost, localPort, containerID, containerPort)
84+
return newNetnsTunnel(auth, sshHost, localPort, containerID, containerPort)
4085
}
4186

42-
func newDirectTunnel(sshHost string, localPort, hostPort uint16) (*Tunnel, error) {
87+
func newDirectTunnel(auth sshAuth, sshHost string, localPort, hostPort uint16) (*Tunnel, error) {
4388
user, addr := parseSSHHost(sshHost)
4489
host, port := splitHostPort(addr)
4590

@@ -56,16 +101,15 @@ func newDirectTunnel(sshHost string, localPort, hostPort uint16) (*Tunnel, error
56101
"-N",
57102
"-L", localBind,
58103
"-l", user,
59-
"-o", "StrictHostKeyChecking=accept-new",
60-
"-o", "ConnectTimeout=10",
61104
"-o", "ExitOnForwardFailure=yes",
62-
"-o", "BatchMode=yes",
63105
"-p", port,
64-
host,
65106
}
107+
args = append(args, auth.baseArgs()...)
108+
args = append(args, host)
66109

67110
cmd := exec.Command("ssh", args...)
68111
cmd.Stdin = nil
112+
auth.apply(cmd)
69113

70114
var stderr bytes.Buffer
71115
cmd.Stderr = &stderr
@@ -114,11 +158,11 @@ func newDirectTunnel(sshHost string, localPort, hostPort uint16) (*Tunnel, error
114158
return nil, fmt.Errorf("tunnel did not become ready within 5s")
115159
}
116160

117-
func newNetnsTunnel(sshHost string, localPort uint16, containerID string, containerPort uint16) (*Tunnel, error) {
161+
func newNetnsTunnel(auth sshAuth, sshHost string, localPort uint16, containerID string, containerPort uint16) (*Tunnel, error) {
118162
user, addr := parseSSHHost(sshHost)
119163
host, port := splitHostPort(addr)
120164

121-
if err := ensureSocatImage(user, host, port); err != nil {
165+
if err := ensureSocatImage(auth, user, host, port); err != nil {
122166
return nil, err
123167
}
124168

@@ -138,38 +182,38 @@ func newNetnsTunnel(sshHost string, localPort uint16, containerID string, contai
138182
containerID, socatImage, containerPort,
139183
)
140184

141-
go t.acceptLoop(user, host, port, remoteCmd)
185+
go t.acceptLoop(auth, user, host, port, remoteCmd)
142186

143187
return t, nil
144188
}
145189

146-
func (t *Tunnel) acceptLoop(user, host, port, remoteCmd string) {
190+
func (t *Tunnel) acceptLoop(auth sshAuth, user, host, port, remoteCmd string) {
147191
for {
148192
conn, err := t.listener.Accept()
149193
if err != nil {
150194
return
151195
}
152-
go t.handleConn(conn, user, host, port, remoteCmd)
196+
go t.handleConn(auth, conn, user, host, port, remoteCmd)
153197
}
154198
}
155199

156-
func (t *Tunnel) handleConn(conn net.Conn, user, host, port, remoteCmd string) {
200+
func (t *Tunnel) handleConn(auth sshAuth, conn net.Conn, user, host, port, remoteCmd string) {
157201
defer conn.Close()
158202

159-
cmd := exec.Command("ssh",
203+
args := []string{
160204
"-l", user,
161205
"-p", port,
162-
"-o", "StrictHostKeyChecking=accept-new",
163-
"-o", "ConnectTimeout=10",
164-
"-o", "BatchMode=yes",
165206
"-o", "ControlMaster=auto",
166207
"-o", "ControlPath=/tmp/d4s-ssh-%r@%h-%p",
167208
"-o", "ControlPersist=60s",
168-
host,
169-
remoteCmd,
170-
)
209+
}
210+
args = append(args, auth.baseArgs()...)
211+
args = append(args, host, remoteCmd)
212+
213+
cmd := exec.Command("ssh", args...)
171214
cmd.Stdin = conn
172215
cmd.Stdout = conn
216+
auth.apply(cmd)
173217

174218
t.mu.Lock()
175219
if t.closed {
@@ -186,20 +230,20 @@ func (t *Tunnel) handleConn(conn net.Conn, user, host, port, remoteCmd string) {
186230
t.mu.Unlock()
187231
}
188232

189-
func ensureSocatImage(user, host, port string) error {
233+
func ensureSocatImage(auth sshAuth, user, host, port string) error {
190234
check := fmt.Sprintf(
191235
"docker image inspect %s >/dev/null 2>&1 || docker pull %s >/dev/null 2>&1",
192236
socatImage, socatImage,
193237
)
194-
cmd := exec.Command("ssh",
238+
args := []string{
195239
"-l", user,
196240
"-p", port,
197-
"-o", "StrictHostKeyChecking=accept-new",
198-
"-o", "ConnectTimeout=10",
199-
"-o", "BatchMode=yes",
200-
host,
201-
check,
202-
)
241+
}
242+
args = append(args, auth.baseArgs()...)
243+
args = append(args, host, check)
244+
245+
cmd := exec.Command("ssh", args...)
246+
auth.apply(cmd)
203247
var stderr bytes.Buffer
204248
cmd.Stderr = &stderr
205249
if err := cmd.Run(); err != nil {

0 commit comments

Comments
 (0)