Skip to content

Commit 559671c

Browse files
authored
Merge pull request #6 from ananthb/feat/ssh-server
sshd: implement --ssh.enable with a pure-Go OpenSSH server
2 parents 5e8713e + 93ddca1 commit 559671c

9 files changed

Lines changed: 521 additions & 12 deletions

File tree

_config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ plugins:
55
- jekyll-remote-theme
66
- jekyll-relative-links
77
- jekyll-optional-front-matter
8+
- jekyll-readme-index
89
relative_links:
910
enabled: true
1011
collections: true

docs/rescue.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,17 @@ sudo xmorph pivot --image alpine \
164164
sudo xmorph pivot --image alpine
165165
```
166166

167-
The dropbear path doesn't need Tailscale but assumes the network already
168-
works: your machine's interface stays up across the pivot, but if the
169-
broken OS had odd routing or firewall rules, those die with it. The
170-
firewall is flushed by default during pivot; pass `--keep-firewall` to
171-
keep it.
167+
The `--ssh.enable` path stands up a small pure-Go OpenSSH server inside
168+
the pivoted rootfs on the given port (default 22) with an ephemeral
169+
ed25519 host key. Auth is public-key (from `--ssh.authorized-keys`,
170+
standard `authorized_keys` format, one per line) and/or password
171+
(`--ssh.password`); at least one must be configured. Sessions run
172+
`/bin/sh` as root — the rescue rootfs has no user database.
173+
174+
It assumes the network already works: your machine's interface stays
175+
up across the pivot, but if the broken OS had odd routing or firewall
176+
rules, those die with it. The firewall is flushed by default during
177+
pivot; pass `--keep-firewall` to keep it.
172178

173179
## Headless flag details
174180

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ module github.com/ananthb/xmorph
33
go 1.26.4
44

55
require (
6+
github.com/creack/pty v1.1.24
67
github.com/google/go-containerregistry v0.21.7
78
github.com/spf13/cobra v1.10.2
89
github.com/spf13/pflag v1.0.10
10+
golang.org/x/crypto v0.52.0
911
golang.org/x/sys v0.46.0
1012
golang.org/x/term v0.44.0
1113
tailscale.com v1.100.0
@@ -49,7 +51,6 @@ require (
4951
github.com/x448/float16 v0.8.4 // indirect
5052
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
5153
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
52-
golang.org/x/crypto v0.52.0 // indirect
5354
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
5455
golang.org/x/net v0.55.0 // indirect
5556
golang.org/x/oauth2 v0.36.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,6 @@ github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15
195195
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
196196
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
197197
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
198-
github.com/tailscale/wireguard-go v0.0.0-20260618005210-c32c33ada7f2 h1:3+YAskVNeyawqRBgkVNEHSWwifTwkDu7EqP/XpO6dLI=
199-
github.com/tailscale/wireguard-go v0.0.0-20260618005210-c32c33ada7f2/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
200198
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
201199
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
202200
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=

internal/cli/dryrun.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ func printDryRun(w io.Writer, cfg *config.Config) {
7878
fmt.Fprintf(w, " - Args: %s\n", tsArgs)
7979
}
8080

81+
if cfg.SSHEnabled() {
82+
fmt.Fprintf(w, " %d. Start SSH server\n", step)
83+
step++
84+
port := uint16(22)
85+
if cfg.SSHPort != nil {
86+
port = *cfg.SSHPort
87+
}
88+
fmt.Fprintf(w, " - Port: %d\n", port)
89+
if cfg.SSHAuthorizedKeys != "" {
90+
fmt.Fprintf(w, " - Auth: public key (%d configured)\n", strings.Count(strings.TrimSpace(cfg.SSHAuthorizedKeys), "\n")+1)
91+
}
92+
if cfg.SSHPassword != "" {
93+
fmt.Fprintf(w, " - Auth: password\n")
94+
}
95+
}
96+
8197
if !cfg.NoInitCoord {
8298
fmt.Fprintf(w, " %d. Coordinate with init system (%s)\n", step, initsys.Detect())
8399
step++

internal/postpivot/run.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ func Run(argv []string) int {
4343
defer wd.Close()
4444
}
4545

46-
// SSH setup: M4's stub. Dropbear bring-up arrives at M5/M6 — for
47-
// now we just log so behavior is visible in --contain integration
48-
// tests.
4946
if cfg != nil && cfg.SSH != nil {
50-
slog.Info("ssh requested", "port", cfg.SSH.Port)
47+
go func() {
48+
if err := StartSSHServer(context.Background(), cfg.SSH); err != nil {
49+
slog.Error("sshd", "err", err)
50+
}
51+
}()
5152
}
5253

5354
// Tailscale: re-open the tsnet state persisted by the pre-pivot

internal/postpivot/sshd_linux.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
//go:build linux
2+
3+
package postpivot
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"crypto/ed25519"
9+
"crypto/rand"
10+
"crypto/subtle"
11+
"errors"
12+
"fmt"
13+
"io"
14+
"log/slog"
15+
"net"
16+
"os"
17+
"os/exec"
18+
"strings"
19+
"sync"
20+
21+
"github.com/creack/pty"
22+
"golang.org/x/crypto/ssh"
23+
)
24+
25+
// StartSSHServer runs an SSH server for the post-pivot rescue rootfs.
26+
// Blocks until ctx is cancelled or the listener errors. Public-key
27+
// (from cfg.AuthorizedKeys, OpenSSH `authorized_keys` format) and
28+
// password (cfg.Password) auth are both accepted when configured; the
29+
// server rejects clients when neither is set. Sessions run `/bin/sh`
30+
// as root — the pivoted rootfs has no user database.
31+
func StartSSHServer(ctx context.Context, cfg *SSHConfig) error {
32+
if cfg == nil {
33+
return errors.New("sshd: nil SSH config")
34+
}
35+
port := cfg.Port
36+
if port == 0 {
37+
port = 22
38+
}
39+
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
40+
if err != nil {
41+
return fmt.Errorf("sshd: listen :%d: %w", port, err)
42+
}
43+
return serveSSH(ctx, ln, cfg)
44+
}
45+
46+
// serveSSH is the injectable core so tests can pass a random-port
47+
// listener. Closes ln on exit.
48+
func serveSSH(ctx context.Context, ln net.Listener, cfg *SSHConfig) error {
49+
defer ln.Close()
50+
51+
sc, err := buildServerConfig(cfg)
52+
if err != nil {
53+
return err
54+
}
55+
56+
go func() {
57+
<-ctx.Done()
58+
ln.Close()
59+
}()
60+
61+
slog.Info("sshd: listening", "addr", ln.Addr())
62+
63+
var wg sync.WaitGroup
64+
defer wg.Wait()
65+
for {
66+
conn, err := ln.Accept()
67+
if err != nil {
68+
if ctx.Err() != nil {
69+
return nil
70+
}
71+
return fmt.Errorf("sshd: accept: %w", err)
72+
}
73+
wg.Add(1)
74+
go func() {
75+
defer wg.Done()
76+
handleConn(conn, sc)
77+
}()
78+
}
79+
}
80+
81+
func buildServerConfig(cfg *SSHConfig) (*ssh.ServerConfig, error) {
82+
sc := &ssh.ServerConfig{}
83+
84+
keys, err := parseAuthorizedKeys(cfg.AuthorizedKeys)
85+
if err != nil {
86+
return nil, fmt.Errorf("sshd: parse authorized_keys: %w", err)
87+
}
88+
if len(keys) > 0 {
89+
sc.PublicKeyCallback = func(_ ssh.ConnMetadata, k ssh.PublicKey) (*ssh.Permissions, error) {
90+
gotBytes := k.Marshal()
91+
for _, want := range keys {
92+
if bytes.Equal(gotBytes, want.Marshal()) {
93+
return nil, nil
94+
}
95+
}
96+
return nil, errors.New("unauthorized key")
97+
}
98+
}
99+
if cfg.Password != "" {
100+
pw := []byte(cfg.Password)
101+
sc.PasswordCallback = func(_ ssh.ConnMetadata, got []byte) (*ssh.Permissions, error) {
102+
if subtle.ConstantTimeCompare(pw, got) == 1 {
103+
return nil, nil
104+
}
105+
return nil, errors.New("bad password")
106+
}
107+
}
108+
if sc.PublicKeyCallback == nil && sc.PasswordCallback == nil {
109+
return nil, errors.New("sshd: no auth method configured (need authorized_keys or password)")
110+
}
111+
112+
signer, err := generateHostKey()
113+
if err != nil {
114+
return nil, fmt.Errorf("sshd: host key: %w", err)
115+
}
116+
sc.AddHostKey(signer)
117+
slog.Info("sshd: host key", "fingerprint", ssh.FingerprintSHA256(signer.PublicKey()))
118+
return sc, nil
119+
}
120+
121+
func parseAuthorizedKeys(s string) ([]ssh.PublicKey, error) {
122+
var out []ssh.PublicKey
123+
for i, line := range strings.Split(s, "\n") {
124+
line = strings.TrimSpace(line)
125+
if line == "" || strings.HasPrefix(line, "#") {
126+
continue
127+
}
128+
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
129+
if err != nil {
130+
return nil, fmt.Errorf("line %d: %w", i+1, err)
131+
}
132+
out = append(out, k)
133+
}
134+
return out, nil
135+
}
136+
137+
func generateHostKey() (ssh.Signer, error) {
138+
_, priv, err := ed25519.GenerateKey(rand.Reader)
139+
if err != nil {
140+
return nil, err
141+
}
142+
return ssh.NewSignerFromKey(priv)
143+
}
144+
145+
func handleConn(conn net.Conn, sc *ssh.ServerConfig) {
146+
defer conn.Close()
147+
sconn, chans, reqs, err := ssh.NewServerConn(conn, sc)
148+
if err != nil {
149+
slog.Warn("sshd: handshake", "err", err, "remote", conn.RemoteAddr())
150+
return
151+
}
152+
defer sconn.Close()
153+
slog.Info("sshd: accepted", "user", sconn.User(), "remote", conn.RemoteAddr())
154+
155+
go ssh.DiscardRequests(reqs)
156+
157+
for newCh := range chans {
158+
if newCh.ChannelType() != "session" {
159+
_ = newCh.Reject(ssh.UnknownChannelType, "session only")
160+
continue
161+
}
162+
ch, chReqs, err := newCh.Accept()
163+
if err != nil {
164+
slog.Warn("sshd: channel accept", "err", err)
165+
continue
166+
}
167+
go handleSession(ch, chReqs)
168+
}
169+
}
170+
171+
// SSH request payloads per RFC 4254; ssh.Unmarshal decodes them.
172+
type (
173+
ptyReqMsg struct {
174+
Term string
175+
Cols, Rows uint32
176+
Width, Height uint32
177+
Modes string
178+
}
179+
winChMsg struct {
180+
Cols, Rows uint32
181+
Width, Height uint32
182+
}
183+
execMsg struct{ Command string }
184+
envMsg struct{ Name, Value string }
185+
exitMsg struct{ Status uint32 }
186+
)
187+
188+
func handleSession(ch ssh.Channel, reqs <-chan *ssh.Request) {
189+
defer ch.Close()
190+
191+
var (
192+
term string
193+
env []string
194+
wantPty bool
195+
ws pty.Winsize
196+
)
197+
198+
for req := range reqs {
199+
switch req.Type {
200+
case "pty-req":
201+
var m ptyReqMsg
202+
if err := ssh.Unmarshal(req.Payload, &m); err != nil {
203+
_ = req.Reply(false, nil)
204+
continue
205+
}
206+
term = m.Term
207+
ws = pty.Winsize{Rows: uint16(m.Rows), Cols: uint16(m.Cols), X: uint16(m.Width), Y: uint16(m.Height)}
208+
wantPty = true
209+
_ = req.Reply(true, nil)
210+
case "env":
211+
var m envMsg
212+
if err := ssh.Unmarshal(req.Payload, &m); err == nil {
213+
env = append(env, m.Name+"="+m.Value)
214+
}
215+
_ = req.Reply(true, nil)
216+
case "shell":
217+
_ = req.Reply(true, nil)
218+
runSession(ch, reqs, exec.Command("/bin/sh", "-l"), term, env, wantPty, ws)
219+
return
220+
case "exec":
221+
var m execMsg
222+
if err := ssh.Unmarshal(req.Payload, &m); err != nil {
223+
_ = req.Reply(false, nil)
224+
continue
225+
}
226+
_ = req.Reply(true, nil)
227+
runSession(ch, reqs, exec.Command("/bin/sh", "-c", m.Command), term, env, wantPty, ws)
228+
return
229+
default:
230+
_ = req.Reply(false, nil)
231+
}
232+
}
233+
}
234+
235+
func runSession(ch ssh.Channel, reqs <-chan *ssh.Request, cmd *exec.Cmd, term string, env []string, wantPty bool, ws pty.Winsize) {
236+
if term != "" {
237+
env = append(env, "TERM="+term)
238+
}
239+
cmd.Env = append(os.Environ(), env...)
240+
241+
if wantPty {
242+
f, err := pty.Start(cmd)
243+
if err != nil {
244+
fmt.Fprintf(ch, "exec: %v\r\n", err)
245+
sendExitStatus(ch, 127)
246+
return
247+
}
248+
defer f.Close()
249+
_ = pty.Setsize(f, &ws)
250+
251+
go io.Copy(f, ch)
252+
go handleWinCh(reqs, f)
253+
_, _ = io.Copy(ch, f)
254+
} else {
255+
cmd.Stdin = ch
256+
cmd.Stdout = ch
257+
cmd.Stderr = ch.Stderr()
258+
if err := cmd.Start(); err != nil {
259+
fmt.Fprintf(ch.Stderr(), "exec: %v\n", err)
260+
sendExitStatus(ch, 127)
261+
return
262+
}
263+
go handleWinCh(reqs, nil)
264+
}
265+
266+
status := uint32(0)
267+
if err := cmd.Wait(); err != nil {
268+
var ee *exec.ExitError
269+
if errors.As(err, &ee) {
270+
status = uint32(ee.ExitCode())
271+
} else {
272+
status = 1
273+
}
274+
}
275+
sendExitStatus(ch, status)
276+
}
277+
278+
func handleWinCh(reqs <-chan *ssh.Request, ptyF *os.File) {
279+
for req := range reqs {
280+
if req.Type == "window-change" && ptyF != nil {
281+
var m winChMsg
282+
if err := ssh.Unmarshal(req.Payload, &m); err == nil {
283+
_ = pty.Setsize(ptyF, &pty.Winsize{Rows: uint16(m.Rows), Cols: uint16(m.Cols), X: uint16(m.Width), Y: uint16(m.Height)})
284+
}
285+
}
286+
_ = req.Reply(false, nil)
287+
}
288+
}
289+
290+
func sendExitStatus(ch ssh.Channel, status uint32) {
291+
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(&exitMsg{Status: status}))
292+
}

0 commit comments

Comments
 (0)