/
ssh.go
187 lines (161 loc) · 5.16 KB
/
ssh.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
// SPDX-License-Identifier: MIT
// Package container for dealing with containers via dagger
package container
import (
"context"
"errors"
"fmt"
"log"
"math/rand"
"strings"
"sync"
"dagger.io/dagger"
)
// ErrParseAddress is raised when containers IP address could not be parsed
var ErrParseAddress = errors.New("could not parse address string")
// OptsOpenSSH stores options for SSH tunnel for OpenSSH function
type OptsOpenSSH struct {
WaitFunc func() // Waiting function holding container with SSH running
Password string // Filled in by OpenSSH function
IPv4 string // Filled in by OpenSSH function
Port string // Filled in by OpenSSH function
MutexData *sync.Mutex // Mutex for modifying data in this struct
// We could do with single channel here, but for clarity and less mental overhead there are 2
TunnelClose chan (bool) // Channel to signal that SSH tunnel is ready
TunnelReady chan (bool) // Channel to signal that SSH tunnel is not longer needed and can be closed
}
// Wait calls WaitFunc
func (s OptsOpenSSH) Wait() {
s.WaitFunc()
}
// Address function parses provided string and populates IPv4 and Port (port defaults to 22 if not found)
func (s *OptsOpenSSH) Address(address string) error {
s.MutexData.Lock()
var err error
sshAddressSplit := strings.Split(address, ":")
switch len(sshAddressSplit) {
case 1:
// IP address but no port
s.IPv4 = sshAddressSplit[0]
s.Port = "22"
case 2:
// Possibly both IP address and port
s.IPv4 = sshAddressSplit[0]
if s.IPv4 == "" {
err = fmt.Errorf("%w: '%s'", ErrParseAddress, address)
}
s.Port = sshAddressSplit[1]
if s.Port == "" {
s.Port = "22"
}
default:
err = fmt.Errorf("%w: '%s'", ErrParseAddress, address)
}
s.MutexData.Unlock()
return err
}
// SettingsSSH is for functional option pattern
type SettingsSSH func(*OptsOpenSSH)
// WithWaitPressEnter is one possible function to pass into OpenSSH
// It will wait until user presses ENTER key to shutdown the container
func WithWaitPressEnter() SettingsSSH {
return func(s *OptsOpenSSH) {
s.WaitFunc = func() {
<-s.TunnelReady
fmt.Print("Press ENTER to stop container ")
fmt.Scanln()
s.TunnelClose <- true
}
}
}
// WithWaitNone is one possible function to pass into OpenSSH
// It will not wait
func WithWaitNone() SettingsSSH {
return func(s *OptsOpenSSH) {
s.WaitFunc = func() {
fmt.Println("Skipping waiting")
}
}
}
// NewSettingsSSH returns a SettingsSSH
func NewSettingsSSH(opts ...SettingsSSH) *OptsOpenSSH {
// Defaults
var m sync.Mutex
s := &OptsOpenSSH{
MutexData: &m,
TunnelClose: make(chan (bool)),
TunnelReady: make(chan (bool)),
}
WithWaitPressEnter()(s)
for _, opt := range opts {
opt(s)
}
return s
}
// OpenSSH takes a container and starts SSH server with port exposed to the host
func OpenSSH(
ctx context.Context,
client *dagger.Client,
container *dagger.Container,
workdir string,
opts *OptsOpenSSH,
) error {
// Example in docs:
// https://docs.dagger.io/cookbook/#expose-service-containers-to-host
// This feature is untested and instead relies on tears, blood and sweat produced during
// it's development to work.
// UPDATE: After more tears, blood and sweat we also have some testing! Yippee!
if container == nil {
log.Println("skipping SSH because no container was given")
return nil
}
if workdir == "" {
workdir = "/"
}
// Generate a password for the root user
opts.MutexData.Lock()
opts.Password = generatePassword(16)
opts.MutexData.Unlock()
// Prepare the container
container = container.
WithExec([]string{"bash", "-c", fmt.Sprintf("echo 'root:%s' | chpasswd", opts.Password)}).
WithExec([]string{"bash", "-c", fmt.Sprintf("echo 'cd %s' >> /root/.bashrc", workdir)}).
WithExec([]string{"/usr/sbin/sshd", "-D"})
// Convert container to service with exposed SSH port
const sshPort = 22
sshServiceDoc := container.WithExposedPort(sshPort).AsService()
// Expose the SSH server to the host
sshServiceTunnel, err := client.Host().Tunnel(sshServiceDoc).Start(ctx)
if err != nil {
fmt.Println("Problem getting tunnel up")
return err
}
defer sshServiceTunnel.Stop(ctx) // nolint:errcheck
// Get and print instructions on how to connect
sshAddress, err := sshServiceTunnel.Endpoint(ctx)
errAddr := opts.Address(sshAddress)
if err != nil || errAddr != nil {
fmt.Println("problem getting address")
return errors.Join(err, errAddr)
}
fmt.Printf("Connect into the container with:\n ssh root@%s -p %s -o PreferredAuthentications=password\n", opts.IPv4, opts.Port)
fmt.Printf("Password is:\n %s\n", opts.Password)
fmt.Println("SSH up and running")
// Wait for user to press key
go opts.Wait()
opts.TunnelReady <- true
<-opts.TunnelClose
fmt.Println("DONE")
return nil
}
func generatePassword(length int) string {
// I suppose we could use crypto/rand, but this seems simpler
// Also, it is meant only as temporary password for a temporary container which gets
// shut-down / removed afterwards. I think it is good enough.
characters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
pass := make([]rune, length)
for i := range pass {
pass[i] = characters[rand.Intn(len(characters))]
}
return string(pass)
}