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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
package main
import (
"cmp"
"context"
"fmt"
"net"
"slices"
"strconv"
"strings"
"time"
"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
"github.com/johnandersen777/socialweb-computer-kcp/internal/app/compose"
"github.com/johnandersen777/socialweb-computer-kcp/internal/app/marketnetwork"
"github.com/johnandersen777/socialweb-computer-kcp/internal/app/sshgateway"
"github.com/johnandersen777/socialweb-computer-kcp/internal/bootstrap"
"github.com/johnandersen777/socialweb-computer-kcp/internal/clicfg"
"github.com/johnandersen777/socialweb-computer-kcp/internal/devinstance"
"github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc"
"github.com/johnandersen777/socialweb-computer-kcp/internal/irohdial"
"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/fsx"
"github.com/johnandersen777/socialweb-computer-kcp/internal/stack/gatewayssh"
"github.com/johnandersen777/socialweb-computer-kcp/internal/stack/request"
)
var gatewaySshOptions = slices.Concat([]string{backendOpt, "profile", "network", "name", "instance", "state-dir", "fg", "ready-timeout",
"network-timeout", "kcp-path", "kine-path", "bao-path", "dumbpipe-path", "asset-cache"})
type gatewaySshRun struct {
a *app
vals clicfg.Values
profile string
network string
options []v1alpha1.ApplicationOption
comms *v1alpha1.MarketComms
value *v1alpha1.MarketNetworkValue
bidderDids []string
ownerSessions string
requesterIngress *v1alpha1.IngressConfig
ssh *v1alpha1.SshGatewaySsh
networkTimeout time.Duration
}
type gatewaySshSummary struct {
Profile string `json:"profile"`
Network string `json:"network,omitempty"`
Name string `json:"name,omitempty"`
Instance string `json:"instance,omitempty"`
KcpURL string `json:"kcpUrl,omitempty"`
Phase v1alpha1.ApplicationPhase `json:"phase,omitempty"`
Message string `json:"message,omitempty"`
SSH string `json:"ssh,omitempty"`
Listeners []v1alpha1.ApplicationListener `json:"listeners,omitempty"`
Components []v1alpha1.ApplicationComponent `json:"components,omitempty"`
Seconds float64 `json:"seconds"`
}
func (r *gatewaySshRun) log(msg string, args ...any) {
r.a.log.Info(msg, append([]any{"verb", "run gateway ssh", "profile", r.profile, "utc", time.Now().UTC().Format(time.RFC3339)}, args...)...)
}
func (r *gatewaySshRun) passthrough(args []string) error {
parsed, err := compose.ParseAppArgs(sshgateway.SshGatewayApp, args)
if err != nil {
return err
}
var nv v1alpha1.MarketNetworkValue
nvSet := false
for _, p := range parsed {
switch p.Name {
case "test-owner-sessions-file":
r.ownerSessions = p.Value
continue
case "bidder-dids":
r.bidderDids = append(r.bidderDids, splitList(p.Value)...)
continue
case "ssh-public-listen", "ssh-web-listen", "ssh-operator-did", "ssh-max-accounts":
if err := r.applySshOption(p.Name, p.Value); err != nil {
return err
}
continue
case "plc-directory-url", "ingress-proxy-host", "network-hosts":
if r.network != "" {
return fmt.Errorf("--%s is set by the market network for its child applications", p.Name)
}
nvSet = true
switch p.Name {
case "plc-directory-url":
nv.PLCDirectoryURL = p.Value
case "ingress-proxy-host":
nv.IngressProxyHost = p.Value
case "network-hosts":
for _, h := range splitList(p.Value) {
name, addr, ok := strings.Cut(h, "=")
if !ok {
return fmt.Errorf("--network-hosts %q: want name=address,...", p.Value)
}
nv.Hosts = append(nv.Hosts, v1alpha1.NetworkHost{Name: name, Address: addr})
}
}
continue
}
if p.To == compose.MapToOptions {
r.options = append(r.options, v1alpha1.ApplicationOption{Name: p.Name, Value: p.Value})
continue
}
if strings.HasPrefix(p.To, "spec.comms") {
var applied bool
var err error
if r.comms, applied, err = applyCommsOption(r.comms, p.Name, p.Value); err != nil {
return err
}
if !applied {
return fmt.Errorf("--%s maps to %s, which run gateway ssh does not take", p.Name, p.To)
}
continue
}
if strings.HasPrefix(p.To, "spec.requesterIngress") {
var applied bool
var err error
if r.requesterIngress, applied, err = applyIngressOption(r.requesterIngress, p.Name, p.Value); err != nil {
return err
}
if !applied {
return fmt.Errorf("--%s maps to %s, which run gateway ssh does not take", p.Name, p.To)
}
continue
}
return fmt.Errorf("--%s maps to %s, which run gateway ssh does not take", p.Name, p.To)
}
if nvSet {
r.value = &nv
}
return nil
}
func (r *gatewaySshRun) applySshOption(name, value string) error {
if r.ssh == nil {
r.ssh = &v1alpha1.SshGatewaySsh{}
}
value = strings.TrimSpace(value)
switch name {
case "ssh-operator-did":
if !strings.HasPrefix(value, "did:") || strings.ContainsAny(value, " /") {
return fmt.Errorf("--ssh-operator-did %q is not a DID", value)
}
r.ssh.OperatorDid = value
case "ssh-max-accounts":
n, err := strconv.Atoi(value)
if err != nil || n < 1 {
return fmt.Errorf("--ssh-max-accounts %q: want a positive number", value)
}
r.ssh.MaxAccounts = int32(n)
case "ssh-public-listen", "ssh-web-listen":
host, port, err := net.SplitHostPort(value)
if err != nil {
return fmt.Errorf("--%s %q: want host:port: %w", name, value, err)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("--%s %q: want a port from 1 to 65535", name, value)
}
if name == "ssh-web-listen" {
r.ssh.WebListen = net.JoinHostPort(host, port)
} else {
r.ssh.PublicListen = net.JoinHostPort(host, port)
}
}
return nil
}
func runGatewaySsh(ctx context.Context, a *app, vals clicfg.Values, rest []string) int {
pos, pass := splitWords(rest)
if len(pos) > 0 {
return a.fail(exitRefused, "run gateway ssh: unexpected %q; hono-socialweb-computer-ssh options go after --", strings.Join(pos, " "))
}
r := &gatewaySshRun{a: a, vals: vals, profile: vals.String("profile"), network: vals.String("network")}
var err error
if r.networkTimeout, err = time.ParseDuration(vals.String("network-timeout")); err != nil {
return a.fail(exitRefused, "run gateway ssh: network-timeout: %v", err)
}
if r.network != "" && (r.profile == "" || r.profile == sshgateway.SshGatewayProfileDefault) {
r.profile = sshgateway.SshGatewayProfileNetwork
}
r.profile = cmp.Or(r.profile, sshgateway.SshGatewayProfileDefault)
if !slices.Contains(sshgateway.SshGatewayProfiles, r.profile) {
return a.fail(exitRefused, "run gateway ssh: --profile %q: want one of %s", r.profile, strings.Join(sshgateway.SshGatewayProfiles, ", "))
}
if err := r.passthrough(pass); err != nil {
return a.fail(exitRefused, "run gateway ssh: %v", err)
}
backend, err := hostsvc.ParseBackend(vals.String(backendOpt))
if err != nil {
return a.fail(exitRefused, "run gateway ssh: %v", err)
}
switch {
case r.network != "" && backend != hostsvc.BackendProc:
return a.fail(exitRefused, "run gateway ssh: --network: the market network places its children under its own devsup; pass --host-backend=proc")
case r.network != "" && r.ssh != nil:
return a.fail(exitRefused, "run gateway ssh: the --ssh-* options are options of a standalone ssh gateway; a market network child listens on loopback only")
case r.network != "":
return r.inNetwork(ctx)
case r.profile == sshgateway.SshGatewayProfileNetwork:
return a.fail(exitRefused, "run gateway ssh: --profile %s needs --network <market network instance> (dev up --profile market-network)", r.profile)
}
return r.standalone(ctx)
}
func (r *gatewaySshRun) name() (string, error) {
name := r.vals.String("name")
name = cmp.Or(name, gatewayssh.DefaultName)
return name, request.CheckName(name)
}
func (r *gatewaySshRun) report(sum gatewaySshSummary, st v1alpha1.ApplicationStatus) gatewaySshSummary {
sum.Phase, sum.Message, sum.Listeners, sum.Components = st.Phase, st.Message, st.Listeners, st.Components
sum.SSH = gatewayssh.SshListener(st.Listeners)
return sum
}
func (r *gatewaySshRun) inNetwork(ctx context.Context) int {
started := time.Now()
l, err := r.a.layoutOf(r.network, r.vals)
if err != nil {
return r.a.fail(exitRefused, "run gateway ssh: %v", err)
}
var netRec devinstance.Record
if err := fsx.ReadJSON(l.Record, &netRec); err != nil {
return r.a.fail(exitRefused, "run gateway ssh: market network %s has no instance record (dev up --profile market-network first): %v", r.network, err)
}
if netRec.Profile != bootstrap.ProfileMarketNetwork || netRec.AdminKubeconfig == "" {
return r.a.fail(exitRefused, "run gateway ssh: instance %s is not a market network (profile %s)", r.network, netRec.Profile)
}
if netRec.Executables[sshgateway.DumbpipeExecutable].Path == "" {
return r.a.fail(exitRefused, "run gateway ssh: market network %s was brought up without dumbpipe; its children reach guests over iroh only (dev up --dumbpipe-path in a dev build)", r.network)
}
name, err := r.name()
if err != nil {
return r.a.fail(exitRefused, "run gateway ssh: --name: %v", err)
}
app := v1alpha1.NetworkApp{Name: name, Kind: v1alpha1.ApplicationKindSshGateway, Profile: r.profile, Options: r.options,
OwnerSessionsFile: r.ownerSessions, RequesterIngress: r.requesterIngress}
cur, found, err := request.NetworkApp(ctx, netRec.AdminKubeconfig, name)
switch {
case err != nil:
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
case !found:
if err := request.AddToNetwork(ctx, netRec.AdminKubeconfig, app); err != nil {
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
}
r.log("the market network places the ssh gateway in its workspace of the network's kcp", "network", r.network, "name", name)
case cur.Message == marketnetwork.StoppedMessage:
if err := request.SetStopped(ctx, netRec.AdminKubeconfig, name, false); err != nil {
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
}
r.log("the market network starts the ssh gateway's kcp again from its kept state", "name", name)
default:
r.log("the market network already runs this ssh gateway; waiting for it to be Ready", "name", name, "phase", string(cur.Phase))
}
st, err := request.WaitNetworkApp(ctx, netRec.AdminKubeconfig, name, r.networkTimeout, func(a v1alpha1.NetworkAppStatus) bool {
return a.Phase == v1alpha1.ApplicationPhaseReady && gatewayssh.SshListener(a.Listeners) != ""
}, func(a v1alpha1.NetworkAppStatus) {
r.log("network child", "name", name, "phase", string(a.Phase), "message", a.Message)
})
sum := r.report(gatewaySshSummary{Profile: r.profile, Network: r.network, Name: name, Instance: st.Instance, KcpURL: st.KcpURL},
v1alpha1.ApplicationStatus{Phase: st.Phase, Message: st.Message, Listeners: st.Listeners, Components: st.Components})
sum.Seconds = time.Since(started).Seconds()
r.a.printJSON(sum)
if err != nil {
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
}
if !r.vals.Bool("fg") {
return exitOK
}
r.log("run gateway ssh --fg: the ssh gateway lives as long as this process; Ctrl-C removes it from the market network", "name", name)
<-ctx.Done()
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), networkDownTimeout)
defer cancel()
if err := request.RemoveFromNetwork(rctx, netRec.AdminKubeconfig, name); err != nil {
return r.a.fail(exitFailed, "run gateway ssh: removing %s from the market network: %v", name, err)
}
return exitOK
}
func (r *gatewaySshRun) standalone(ctx context.Context) int {
started := time.Now()
name, err := r.name()
if err != nil {
return r.a.fail(exitRefused, "run gateway ssh: --name: %v", err)
}
spec, err := gatewayssh.Spec(gatewayssh.SpecInput{Profile: r.profile, Network: r.value, BidderDids: r.bidderDids, Options: r.options, Comms: r.comms, RequesterIngress: r.requesterIngress, Ssh: r.ssh})
if err != nil {
return r.a.fail(exitRefused, "run gateway ssh: %v", err)
}
instance := r.vals.String("instance")
if instance == "" {
instance = gatewayssh.InstanceID(name)
}
vals := r.vals.With("instance", instance).With("profile", bootstrap.ProfileQuick)
if l, err := r.a.layoutOf(instance, vals); err != nil {
return r.a.fail(exitRefused, "run gateway ssh: %v", err)
} else if err := sshgateway.CheckIrohSocket(l); err != nil {
return r.a.fail(exitRefused, "run gateway ssh: %v", err)
}
up, code := bringUp(ctx, r.a, vals, false)
if code != exitOK {
return code
}
if up.Executables[sshgateway.DumbpipeExecutable].Path == "" {
return r.a.fail(exitRefused, "run gateway ssh: the instance has no dumbpipe; the ssh gateway reaches guests over iroh only (--dumbpipe-path in a dev build)")
}
k, err := gatewayssh.OpenKcp(up.AdminKubeconfig)
if err != nil {
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
}
if err := k.ApplyRoot(ctx, spec); err != nil {
return r.a.fail(exitFailed, "run gateway ssh: applying the SshGateway root object: %v", err)
}
r.log("the SshGateway root object is applied into its own kcp", "instance", up.Instance, "kcp", up.KcpURL)
o, err := k.WaitReady(ctx, r.networkTimeout, func(st v1alpha1.ApplicationStatus) {
r.log("ssh gateway", "phase", string(st.Phase), "message", st.Message)
})
sum := gatewaySshSummary{Profile: r.profile, Name: name, Instance: up.Instance, KcpURL: up.KcpURL}
if o != nil {
sum = r.report(sum, o.Status)
}
sum.Seconds = time.Since(started).Seconds()
r.a.printJSON(sum)
if err != nil {
return r.a.fail(exitFailed, "run gateway ssh: %v", err)
}
if !r.vals.Bool("fg") {
return exitOK
}
r.log("run gateway ssh --fg: the ssh gateway lives as long as this process; Ctrl-C stops its instance and keeps its state", "instance", up.Instance)
<-ctx.Done()
dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fgDownTimeout)
defer cancel()
d := downInstance(dctx, r.a, up.layout, true, false)
r.a.printJSON(d)
if len(d.Remaining) > 0 {
return exitFailed
}
return exitOK
}
func runIrohDial(ctx context.Context, a *app, vals clicfg.Values, rest []string) int {
if len(rest) > 0 {
return a.fail(exitRefused, "internal iroh-dial: unexpected %q", strings.Join(rest, " "))
}
err := irohdial.Run(ctx, irohdial.Config{Socket: vals.String("socket"), Dumbpipe: vals.String("dumbpipe-path"), Log: a.log})
if err != nil {
return a.fail(exitFailed, "internal iroh-dial: %v", err)
}
return exitOK
}