Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 146 additions & 52 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion cmd/benchpod-cli/blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ func newInstallBlobsCmd(g *globalFlags) *cobra.Command {
"firmware that checks it too; for now a missing or failing one never stops an install.",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
console, path, ctx, cancel, err := g.openSerialConsole(g.serialDevice(), g.effectiveTimeout(blobsInstallTimeout))
device, err := g.usbDevice("install-blobs")
if err != nil {
return err
}
console, path, ctx, cancel, err := g.openSerialConsole(device, g.effectiveTimeout(blobsInstallTimeout))
if err != nil {
return err
}
Expand Down
95 changes: 86 additions & 9 deletions cmd/benchpod-cli/cloudcfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ func newCloudCmd(g *globalFlags) *cobra.Command {
Long: "Configure the pod's link to embeddedci.com on networks that need it.\n\n" +
" ca a company root certificate, for networks whose proxy inspects TLS\n" +
" proxy an HTTP proxy the pod tunnels its cloud connection through\n\n" +
"Both work over the pod's USB console (--connection usb). The LAN\n" +
"Both work over the pod's USB console (--connection usb) and through\n" +
"embeddedci.com (--connection embeddedci:<name>; a change there needs an\n" +
"organization owner or admin, and `cloud ca set` needs USB). The LAN\n" +
"(--connection <address>) can show them; current firmware refuses changes from\n" +
"the LAN, so change them over USB. The pod reconnects to the cloud after a change.",
Args: cobra.NoArgs,
Expand Down Expand Up @@ -198,22 +200,44 @@ const plainLANPasswordWarning = "Warning: the pod's LAN API is plain TCP, so the
// warnPlainLANPassword warns when the proxy password would go to the pod over the LAN. It stays
// quiet when the target is USB or cannot be resolved (the command then fails with the reason).
func warnPlainLANPassword(g *globalFlags, warn io.Writer) {
if spec, err := cloudCfgTarget(g, "cloud proxy set"); err == nil && spec.IsWifi() {
if spec, err := cloudCfgTarget(g, "cloud proxy set"); err == nil && spec.IsNetwork() {
fmt.Fprintln(warn, plainLANPasswordWarning)
}
}

// cloudCfgTarget resolves --connection for the cloud ca/proxy commands: a serial device ("" =
// auto-detect) or a LAN address.
// auto-detect), a LAN address or a pod on embeddedci.com.
func cloudCfgTarget(g *globalFlags, what string) (spec ConnSpec, err error) {
raw, err := g.rawConnection()
if err != nil {
return ConnSpec{}, err
}
if raw == "" {
return ConnSpec{}, fmt.Errorf("%s: no pod selected; pass --connection usb or --connection <address>", what)
return ConnSpec{}, fmt.Errorf("%s: no pod selected; pass --connection usb, --connection <address> or --connection embeddedci:<name>", what)
}
return classifyConnection(raw)
return parseTarget(raw)
}

// cloudCfgPod runs one cloud_ca / cloud_proxy JSON command through embeddedci.com and decodes the
// reply into out (nil to skip).
func cloudCfgPod(g *globalFlags, spec ConnSpec, def time.Duration, req map[string]any, out any) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(def))
defer cancel()
defer installSignalHandler(ctx, cancel)()
pod, err := openPodTarget(ctx, spec)
if err != nil {
return "", err
}
data, err := pod.client.Command(ctx, req)
if err != nil {
return pod.label, err
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return pod.label, fmt.Errorf("unexpected reply to %v: %s", req["cmd"], strings.TrimSpace(string(data)))
}
}
return pod.label, nil
}

// ── CA certificate checks ───────────────────────────────────────────────────
Expand Down Expand Up @@ -296,6 +320,12 @@ func printCACerts(out io.Writer, label string, certs []serialconsole.CACert, cha

func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error {
what := "cloud ca " + action
if action == "set" {
// Say so before reading the file when the connection cannot carry the upload.
if spec, err := cloudCfgTarget(g, what); err == nil && spec.IsCloud() {
return cloudCASetRefusal(what)
}
}
var local []caCert
var upload []byte
if action == "set" {
Expand All @@ -322,9 +352,14 @@ func runCloudCA(g *globalFlags, action, file string, out, warn io.Writer) error
}
var label string
var certs []serialconsole.CACert
if spec.IsSerial() {
switch {
case spec.IsCloud() && action == "set":
return cloudCASetRefusal(what)
case spec.IsCloud():
label, certs, err = cloudCACloud(g, spec, action)
case spec.IsSerial():
label, certs, err = cloudCAUSB(g, spec.Device, action, upload, warn)
} else {
default:
label = spec.Addr
certs, err = cloudCALAN(g, spec.Addr, action, upload, warn)
}
Expand Down Expand Up @@ -428,6 +463,45 @@ func cloudCAUSB(g *globalFlags, device, action string, upload []byte, warn io.Wr
return label, certs, err
}

// ── embeddedci.com ──────────────────────────────────────────────────────────

// cloudCASetRefusal: installing a CA is an upload, which the command channel does not carry.
func cloudCASetRefusal(what string) error {
return fmt.Errorf("%s does not work over embeddedci.com from the CLI: the certificate upload needs the pod's USB console. "+
"Run it with --connection usb", what)
}

// cloudCACloud shows or clears the company CA through embeddedci.com. The pod reconnects after a
// clear, so the reply to the clear is what it reports; a read-back could race the reconnect.
func cloudCACloud(g *globalFlags, spec ConnSpec, action string) (string, []serialconsole.CACert, error) {
req := map[string]any{"cmd": "cloud_ca"}
if action == "clear" {
req["clear"] = true
}
var rep caReply
label, err := cloudCfgPod(g, spec, 30*time.Second, req, &rep)
return label, rep.Certs, err
}

// cloudProxyCloud shows, sets or clears the HTTP proxy through embeddedci.com (HTTPS, so a proxy
// password does not cross the network in the clear). Like the CA, a change makes the pod
// reconnect, so the change's own reply is what it reports.
func cloudProxyCloud(g *globalFlags, spec ConnSpec, action, set, user, password string) (string, serialconsole.ProxyConfig, error) {
req := map[string]any{"cmd": "cloud_proxy"}
switch action {
case "set":
req["set"] = set
if user != "" {
req["user"], req["password"] = user, password
}
case "clear":
req["clear"] = true
}
var rep proxyReply
label, err := cloudCfgPod(g, spec, 30*time.Second, req, &rep)
return label, rep.config(), err
}

// ── LAN ─────────────────────────────────────────────────────────────────────

// lanSession runs JSON commands on one connection; error replies come back as *tcpclient.PodError.
Expand Down Expand Up @@ -591,9 +665,12 @@ func runCloudProxy(g *globalFlags, action, addr, user, password string, out, war
}
var label string
var p serialconsole.ProxyConfig
if spec.IsSerial() {
switch {
case spec.IsCloud():
label, p, err = cloudProxyCloud(g, spec, action, addr, user, password)
case spec.IsSerial():
label, p, err = cloudProxyUSB(g, spec.Device, action, addr, user, password)
} else {
default:
label = spec.Addr
p, err = cloudProxyLAN(g, spec.Addr, action, addr, user, password)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/benchpod-cli/commands_cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ func TestCloudErrorHint(t *testing.T) {
{cloudStatus{State: "backoff", LastError: s("tcp connect refused")}, "outbound HTTPS"},
{cloudStatus{State: "backoff", LastError: s("tls certificate not trusted (flags 0x8)")}, "certificate"},
{cloudStatus{State: "backoff", LastError: s("tls handshake failed")}, "intercepts HTTPS"},
{cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 404)")}, "does not know this device"},
{cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 404)")}, "does not know this pod"},
{cloudStatus{State: "backoff", LastError: s("server refused the device (HTTP 403)")}, "deregistered"},
{cloudStatus{State: "backoff", LastError: s("websocket upgrade failed (HTTP 403)")}, "rejected"},
{cloudStatus{State: "backoff", LastError: s("connection lost")}, "unreliable"},
Expand Down
41 changes: 20 additions & 21 deletions cmd/benchpod-cli/commands_deregister.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ func newDeregisterCmd(g *globalFlags) *cobra.Command {
var keepPodConfig bool
cmd := &cobra.Command{
Use: "deregister",
Short: "Deregister the bench pod from the server (keeps its data) and stop it connecting",
Long: "Deregister the bench pod from the logged-in user's account.\n\n" +
Short: "Deregister the pod from the server (keeps its data) and stop it connecting",
Long: "Deregister the pod from the logged-in user's account.\n\n" +
"The server keeps everything recorded for the pod — captures, waveforms, wiring —\n" +
"and only marks the device disabled, so it disappears from the web UI and its\n" +
"and only marks the pod disabled, so it disappears from the web UI and its\n" +
"identity is freed for another account. Registering the SAME pod again for the\n" +
"same account revives the device with its data; registering it for a different\n" +
"account gives that account a fresh device and leaves this one's history alone.\n\n" +
"By default the pod is identified by asking the attached bench pod for its public\n" +
"same account revives the pod with its data; registering it for a different\n" +
"account gives that account a fresh pod record and leaves this one's history alone.\n\n" +
"By default the pod is identified by asking the pod for its public\n" +
"key (so --connection must point at it) and is then told to stop connecting to the\n" +
"cloud. Use --device-name/--device-id (or --connection embeddedci:<name>) to\n" +
"deregister a pod you cannot reach.",
Expand All @@ -39,8 +39,8 @@ func newDeregisterCmd(g *globalFlags) *cobra.Command {
}
cmd.Flags().StringVar(&serverURL, "server-url", "https://www.embeddedci.com", "embeddedci-server base URL")
cmd.Flags().StringVar(&tokenFile, "token-file", "", "path to token cache (default: ~/.config/benchpod-cli/token.json)")
cmd.Flags().StringVar(&deviceName, "device-name", "", "deregister the device with this name instead of the attached bench pod")
cmd.Flags().StringVar(&deviceID, "device-id", "", "deregister the device with this id instead of the attached bench pod")
cmd.Flags().StringVar(&deviceName, "device-name", "", "deregister the pod with this name instead of the one --connection reaches")
cmd.Flags().StringVar(&deviceID, "device-id", "", "deregister the pod with this id instead of the one --connection reaches")
cmd.Flags().BoolVar(&keepPodConfig, "keep-pod-config", false,
"leave the pod's cloud configuration in place (it will keep trying to connect and be refused)")
return cmd
Expand Down Expand Up @@ -78,7 +78,7 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st
// --connection embeddedci:<name> is --device-name <name>.
deviceName, usePod = spec.Name, false
} else {
if err := spec.RequireWifi("deregister"); err != nil {
if err := spec.RequireNetwork("deregister"); err != nil {
return err
}
client = &tcpclient.Client{Addr: spec.Addr}
Expand All @@ -95,11 +95,10 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st
defer installSignalHandler(ctx, cancel)()

api := serverapi.New(serverURL)
tokens, err := ensureTokens(ctx, api, tokenPath)
cred, err := credentialFor(ctx, api, tokenFile, tokenPath)
if err != nil {
return fmt.Errorf("auth: %w", err)
}
log.Printf("auth: signed in as %s", tokens.Who())

target := deviceID
label := deviceID
Expand All @@ -110,11 +109,11 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st
pubKey, err = client.IdentityPublic(idCtx)
idCancel()
if err != nil {
return fmt.Errorf("fetch device public key: %w", err)
return fmt.Errorf("fetch the pod's public key: %w", err)
}
}
listCtx, listCancel := context.WithTimeout(ctx, 30*time.Second)
devices, lErr := api.ListDevices(listCtx, tokens.AccessToken)
devices, lErr := api.ListDevices(listCtx, cred)
listCancel()
if lErr != nil {
return fmt.Errorf("list devices: %w", lErr)
Expand All @@ -127,15 +126,15 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st
}

degCtx, degCancel := context.WithTimeout(ctx, 30*time.Second)
device, err := api.DeregisterDevice(degCtx, tokens.AccessToken, target)
device, err := api.DeregisterDevice(degCtx, cred, target)
degCancel()
if err != nil {
return fmt.Errorf("deregister device: %w", err)
return fmt.Errorf("deregister the pod: %w", err)
}
if strings.TrimSpace(device.Name) != "" {
label = device.Name
}
log.Printf("device: deregistered name=%s id=%s (data retained)", label, device.ID)
log.Printf("pod: deregistered name=%s id=%s (data retained)", label, device.ID)

// Wipe the pod-side cloud provisioning so the firmware stops reconnecting. `cloud_clear`
// (not `cloud_set`) is the right tool: it drops the stored endpoint AND the now-dead
Expand All @@ -147,14 +146,14 @@ func runDeregister(g *globalFlags, serverURL, tokenFile, deviceName, deviceID st
_, err = client.Command(setCtx, map[string]any{"cmd": "cloud_clear"})
setCancel()
if err != nil {
log.Printf("warning: could not clear the bench pod's cloud configuration: %v", err)
log.Printf("warning: could not clear the pod's cloud configuration: %v", err)
log.Printf("the pod will keep trying to connect and be refused; re-run with the pod reachable, or clear it by hand")
} else {
log.Printf("provisioned: bench pod cloud configuration cleared")
log.Printf("provisioned: pod cloud configuration cleared")
}
}

fmt.Fprintf(os.Stderr, "Deregistered bench pod %q (device %s). Its data is kept; register it again to restore it.\n",
fmt.Fprintf(os.Stderr, "Deregistered pod %q (id %s). Its data is kept; register it again to restore it.\n",
label, device.ID)
return nil
}
Expand All @@ -171,12 +170,12 @@ func findDeviceToDeregister(devices []serverapi.DeviceResponse, publicKey, name
}
}
return serverapi.DeviceResponse{}, errors.New(
"the attached bench pod is not registered to this account (no device matches its public key)")
"the pod is not registered to this account (no pod on it has its public key)")
}
for _, d := range devices {
if d.Name == name {
return d, nil
}
}
return serverapi.DeviceResponse{}, fmt.Errorf("no device named %q is registered to this account", name)
return serverapi.DeviceResponse{}, fmt.Errorf("no pod named %q is registered to this account", name)
}
6 changes: 6 additions & 0 deletions cmd/benchpod-cli/commands_discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ func runDiscover(g *globalFlags, o discoverOpts) error {
if !o.usb && !o.network {
return errors.New("--no-usb and --no-network together leave nothing to look for")
}
if raw := strings.TrimSpace(g.connection); raw != "" {
if spec, err := parseTarget(raw); err == nil && spec.IsCloud() {
return fmt.Errorf("discover looks for pods on USB and the LAN, not on embeddedci.com (--connection %s%s). "+
"Ask that pod with `benchpod status --connection %s%s`, or see your pods in the web app", cloudConnPrefix, spec.Name, cloudConnPrefix, spec.Name)
}
}

var (
serialPods []serialconsole.SerialPod
Expand Down
Loading
Loading