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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
|
// rsh is rsend's SSH transport. It is invoked three ways:
//
// rsh [-l USER] [USER@]HOST CMD... rsync remote shell (rsync -e), strict
// rsh -keygen generate an ed25519 key, print the private key (PEM)
// rsh -pubkey print the pubkey for RSH_KEY_DATA/RSH_KEY
// rsh -scan USER@HOST connect, print host-key fingerprint + line
//
// Transport mode mirrors what ssh does for rsync: it dials the host, runs the
// remote command, and bridges stdin/stdout/stderr. Host keys are verified
// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud.
// Key, known_hosts path, and port come from the environment because rsync owns
// the argument vector:
//
// RSH_KEY path to the ed25519 private key
// RSH_KEY_DATA the private key itself (PEM), preferred over RSH_KEY so the
// app need never write the plaintext key to disk
// RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode)
// RSH_PORT TCP port (optional, default 22)
//
// Pure Go, no cgo.
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
const dialTimeout = 30 * time.Second
func main() {
if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
var ee *ssh.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitStatus())
}
fmt.Fprintf(os.Stderr, "rsh: %v\n", err)
os.Exit(1)
}
}
func run(args []string, in io.Reader, out, errw io.Writer) error {
if len(args) >= 1 {
switch args[0] {
case "-keygen":
if len(args) != 1 {
return errors.New("usage: rsh -keygen")
}
return keygen(out)
case "-pubkey":
if len(args) != 1 {
return errors.New("usage: rsh -pubkey")
}
return pubkey(out)
case "-scan":
if len(args) != 2 {
return errors.New("usage: rsh -scan USER@HOST")
}
return scan(args[1], out)
}
}
return transport(args, in, out, errw)
}
// transport runs the remote command and bridges I/O, verifying the host key
// strictly against RSH_KNOWN_HOSTS.
func transport(args []string, in io.Reader, out, errw io.Writer) error {
user, host, cmd, err := parseTransport(args)
if err != nil {
return err
}
if user == "" {
return errors.New("no remote user (expected -l USER or USER@HOST)")
}
if len(cmd) == 0 {
return errors.New("no remote command")
}
kh := os.Getenv("RSH_KNOWN_HOSTS")
if kh == "" {
return errors.New("RSH_KNOWN_HOSTS not set")
}
cb, err := knownhosts.New(kh)
if err != nil {
return fmt.Errorf("known_hosts: %w (run Test connection first)", err)
}
client, err := dial(user, host, cb)
if err != nil {
return err
}
defer client.Close()
go keepAlive(client)
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
session.Stdin = in
session.Stdout = out
session.Stderr = errw
return session.Run(strings.Join(cmd, " "))
}
// scan connects (authenticating, so it also proves the key is installed),
// captures the presented host key, and prints its SHA256 fingerprint and a
// known_hosts line for the caller to pin.
func scan(target string, out io.Writer) error {
user, host := splitUserHost(target)
if user == "" {
return errors.New("scan target must be USER@HOST")
}
port, err := sshPort()
if err != nil {
return err
}
var hostKey ssh.PublicKey
capture := func(_ string, _ net.Addr, key ssh.PublicKey) error {
hostKey = key
return nil
}
client, err := dial(user, host, capture)
if err != nil {
return err
}
client.Close()
addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
line := knownhosts.Line([]string{addr}, hostKey)
fmt.Fprintf(out, "%s\n%s\n", ssh.FingerprintSHA256(hostKey), line)
return nil
}
// keygen generates an ed25519 key and prints the private key in PEM form to
// out. Nothing touches disk: the caller owns persistence (the app stores it
// encrypted) and derives the public key with -pubkey.
func keygen(out io.Writer) error {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
block, err := ssh.MarshalPrivateKey(priv, "rsend")
if err != nil {
return err
}
return pem.Encode(out, block)
}
// pubkey loads the private key (RSH_KEY_DATA or RSH_KEY) and prints its public
// key in authorized_keys format. The app uses it to validate and display a key
// the user imported: if loadSigner accepts it here, the transport will too.
func pubkey(out io.Writer) error {
signer, err := loadSigner()
if err != nil {
return err
}
_, err = out.Write(ssh.MarshalAuthorizedKey(signer.PublicKey()))
return err
}
// loadSigner reads the private key from RSH_KEY_DATA (the key itself) or, if
// that is unset, the file named by RSH_KEY.
func loadSigner() (ssh.Signer, error) {
if data := os.Getenv("RSH_KEY_DATA"); data != "" {
return ssh.ParsePrivateKey([]byte(data))
}
keyPath := os.Getenv("RSH_KEY")
if keyPath == "" {
return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set")
}
pemBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("read key: %w", err)
}
return ssh.ParsePrivateKey(pemBytes)
}
// dial opens an SSH connection authenticated with the configured key, verifying
// the host key with hostKey.
func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) {
signer, err := loadSigner()
if err != nil {
return nil, err
}
port, err := sshPort()
if err != nil {
return nil, err
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hostKey,
Timeout: dialTimeout,
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return nil, err
}
if err := conn.SetDeadline(time.Now().Add(dialTimeout)); err != nil {
conn.Close()
return nil, err
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg)
if err != nil {
conn.Close()
return nil, err
}
if err := conn.SetDeadline(time.Time{}); err != nil {
clientConn.Close()
return nil, err
}
return ssh.NewClient(clientConn, chans, reqs), nil
}
// parseTransport extracts the user, host, and remote command from the argument
// vector rsync hands to its remote shell: an optional "-l USER", the host
// (possibly USER@HOST), then the remote command.
func parseTransport(args []string) (user, host string, cmd []string, err error) {
i := 0
for i < len(args) {
a := args[i]
if a == "-l" {
if i+1 >= len(args) {
return "", "", nil, errors.New("-l requires an argument")
}
user = args[i+1]
i += 2
continue
}
if strings.HasPrefix(a, "-") {
return "", "", nil, fmt.Errorf("unexpected option %q", a)
}
break
}
if i >= len(args) {
return "", "", nil, errors.New("missing host")
}
host = args[i]
cmd = args[i+1:]
if u, h := splitUserHost(host); u != "" {
host = h
if user == "" {
user = u
}
}
return user, host, cmd, nil
}
func splitUserHost(s string) (user, host string) {
if i := strings.Index(s, "@"); i >= 0 {
return s[:i], s[i+1:]
}
return "", s
}
func sshPort() (int, error) {
s := os.Getenv("RSH_PORT")
if s == "" {
return 22, nil
}
p, err := strconv.Atoi(s)
if err != nil || p < 1 || p > 65535 {
return 0, fmt.Errorf("invalid RSH_PORT %q", s)
}
return p, nil
}
// keepAlive pings the server so a long transfer is not dropped by an idle NAT.
// It returns once the connection is closed.
func keepAlive(client *ssh.Client) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil {
return
}
}
}
|