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
96 changes: 71 additions & 25 deletions README-install.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,66 @@
# Install Debuglet

Debuglet releases support Linux amd64. For the full bundle, download the archive, `install.sh`, and `SHA256SUMS` from the [latest release](https://github.com/netsec-ethz/debuglet/releases/latest), then install to your user directory:
Use Linux amd64, a POSIX shell, GNU tar, and coreutils. Choose the operator's
exact **full bundle** for executor enrollment, managed services, or the local
demo. No source checkout or compiler is needed. The published unsigned v0.2.0
package predates `dbl executor join`; the current enrollment flow requires an
operator-supplied candidate until a compatible signed release is published.

## Install a private development candidate

Obtain a trusted standalone `bootstrap.sh`, the exact version and source
revision, and the matching `debuglet-VERSION-linux-amd64.tar.gz`, `install.sh`,
and `SHA256SUMS` through your operator's trusted channel. Keep those three
package files together with their original names. Replace the version
placeholder below with the supplied `v0.0.0-dev.<source-prefix>` and use your
actual local paths. Candidates require a local directory and explicit unsigned
opt-in; they are not public releases. Checksums are verified before the installer
runs, but do not authenticate an unsigned publisher.

```sh
archive=$(printf '%s\n' ./debuglet-v*-linux-amd64.tar.gz)
version=${archive#./debuglet-}
version=${version%-linux-amd64.tar.gz}
sh ./install.sh --archive "$archive" --checksums ./SHA256SUMS \
--version "$version" --prefix "$HOME/.local"
DEBUGLET_VERSION=VERSION_PROVIDED_BY_OPERATOR \
DEBUGLET_RELEASE_DIR="$HOME/Downloads/debuglet-candidate" \
DEBUGLET_ALLOW_UNSIGNED=1 \
DEBUGLET_PREFIX="$HOME/.local" \
sh "$HOME/Downloads/bootstrap.sh"
export PATH="$HOME/.local/bin:$PATH"
dbl --output json version
dbl demo
```

The installer verifies the release files before writing them. It requires a POSIX shell, GNU tar, and coreutils. The demo needs no Go compiler, source checkout, service, account, wallet, or root access.
Leave component and signature/trust settings unset for this unsigned full
bundle; unsigned mode cannot override configured signature requirements.
Confirm the reported version and source revision. The demo needs no service,
account, wallet, or root access. Installation does not enroll a node, create a
service account, or restart an existing service; follow [executor
setup](docs/operations/executor-onboarding.md) or [managed
services](docs/operations/services.md) next.

For signed releases, provision trust and verify the installer before execution
using [Signed releases and offline verification](docs/operations/releases.md).
That guide also describes the explicit mode for existing unsigned packages.
## Install a signed release

Once your operator identifies an exact published version with signed assets,
obtain the trusted standalone bootstrap and provision the signer's trust file
independently using [Signed releases and offline
verification](docs/operations/releases.md). Replace the version placeholder
below and leave unsigned, component and local-directory settings unset. This
route also requires curl, Python 3.11 or later, and OpenSSH with `ssh-keygen -Y`.

```sh
DEBUGLET_VERSION=VERSION_PROVIDED_BY_OPERATOR \
DEBUGLET_REQUIRE_SIGNATURE=1 \
DEBUGLET_RELEASE_TRUST=/etc/debuglet/release-allowed-signers \
DEBUGLET_RELEASE_SIGNER=releases@debuglet.netsec.ethz.ch \
DEBUGLET_PREFIX="$HOME/.local" \
sh /path/to/trusted/bootstrap.sh
```

Signature verification precedes installer execution and never falls back to
unsigned mode. For offline use, set `DEBUGLET_RELEASE_DIR` to the package
directory including `release.json` and `release.json.sig`.

For the older public v0.2.0 demo only, use `DEBUGLET_VERSION=v0.2.0
DEBUGLET_ALLOW_UNSIGNED=1 sh /path/to/trusted/bootstrap.sh` with component,
local-directory and signature/trust settings unset.

## Supported platforms

Expand Down Expand Up @@ -54,7 +98,13 @@ make ci-build
make ci-package
```

The full bundle is under `.cache/ci/packages/`. Use it with the same installer command above. The build also produces the component packages below.
The full bundle is under `.cache/ci/packages/`. The build prints its exact
archive name, including the generated `v0.0.0-dev.<source-prefix>` version. Run
the development-candidate command above with that version,
`DEBUGLET_RELEASE_DIR="$PWD/.cache/ci/packages"`, and `sh scripts/bootstrap.sh`.
For another machine, copy the trusted bootstrap and the three package files;
the source checkout stays on the build machine. The build also produces the
component packages below.

The package output includes `compatibility.json` and
`SHA256SUMS-compatibility`. This separate record identifies the core package,
Expand All @@ -73,20 +123,16 @@ Component packages are available from current source builds and are intended for
| Executor operators | `executor` | `.cache/ci/packages/executor/` | `debuglet-executor` |
| Dispatcher operators | `dispatcher` | `.cache/ci/packages/dispatcher/` | `debuglet-dispatcher` |

Choose a component, then run this from the source checkout after building:

```sh
component=cli # or executor or dispatcher
cd ".cache/ci/packages/$component"
archive=$(printf '%s\n' ./debuglet-"$component"-v*-linux-amd64.tar.gz)
version=${archive#./debuglet-$component-}
version=${version%-linux-amd64.tar.gz}
sh "./install-$component.sh" --archive "$archive" \
--checksums "./SHA256SUMS-$component" --version "$version" --prefix "$HOME/.local"
export PATH="$HOME/.local/bin:$PATH"
```

Each directory contains only its archive, installer and checksums. These filenames are also unique release asset names. Role installations share a prefix safely: upgrading one changes only its own command link. The daemon packages include their SQLite support and embedded migrations; they do not require `goose` or `sqlc` at runtime. Daemons still need configuration and a prepared database; see the operator guide below.
Use either bootstrap invocation above with `DEBUGLET_COMPONENT=cli`,
`executor`, or `dispatcher`. For local packages, point `DEBUGLET_RELEASE_DIR`
to that component's directory containing its archive, `install-COMPONENT.sh`,
and `SHA256SUMS-COMPONENT`. Select the same exact version as the full bundle.
Check the installed command with `--help`; the component packages omit the
bundled demo. Role installations share a prefix safely: upgrading one changes
only its own command link. The daemon packages include their SQLite support
and embedded migrations; they do not require
`goose` or `sqlc` at runtime. Daemons still need configuration and a prepared
database; see the operator guide below.

Use the full bundle for `dbl demo`, `dbl up`, `dbl dispatcher up`, `dbl executor up`, and bundled samples. The CLI-only package is for connecting to an existing dispatcher and submitting your own WASM files.

Expand Down
91 changes: 72 additions & 19 deletions cmd/dbl/executor_join.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
Expand All @@ -13,6 +14,7 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"net"
Expand All @@ -35,6 +37,7 @@ import (
const executorJoinUsage = `Usage:
dbl executor join --dispatcher URL --executor ID --state-dir DIR
[--ca-file FILE] [--token-file FILE] [--capacity BITS_PER_SECOND] [--host-tag TAG ...]
[--renew]

Enroll this machine using the one-time token from Console > My nodes.
The token is read without echo from the terminal, or from standard input or
Expand All @@ -43,13 +46,19 @@ listing: home, office, datacentre, academic, cloud, dsl, cable, fibre, wifi,
mobile, satellite or nat; it is written to [metadata] host_tags. HTTPS verifies the dispatcher using the system trust store or
--ca-file; plain HTTP is accepted only on a literal loopback IP.

DIR must not exist. The command creates private identity, configuration and
For a new enrollment DIR must not exist. The command creates private identity, configuration and
storage files, then prints the command to start the executor. It does not
start a service or configure payments. Keep DIR for subsequent restarts.
--capacity sets the bandwidth offered to the dispatcher (default 10000000 bit/s).
Choose a sustainable limit for this host and its uplink.
For a persistent Linux system service, follow docs/operations/executor-onboarding.md
and enroll directly into /var/lib/debuglet/executors/NAME before service install.

--renew replaces only the certificate of an existing managed executor, using a
fresh Set up again token. First drain its service and confirm joined shutdown
and disabled boot startup. Its key, configuration, database and native trust
stay unchanged. Resume the service explicitly after success. See the renewal
procedure in docs/operations/executor-onboarding.md; renewal is not automatic.
`

type executorJoinResponse struct {
Expand All @@ -61,10 +70,15 @@ type executorJoinResponse struct {
}

func executorJoinCommand(ctx context.Context, args []string, options globalOptions, stdout, stderr io.Writer) int {
return executorJoinCommandWith(ctx, args, options, stdout, stderr, productionServiceDependencies())
}

func executorJoinCommandWith(ctx context.Context, args []string, options globalOptions, stdout, stderr io.Writer, deps serviceDependencies) int {
fs := newCommandFlagSet("executor join")
endpoint := fs.String("dispatcher", "", "dispatcher HTTPS URL, including any API prefix")
id := fs.String("executor", "", "executor ID shown in the console")
state := fs.String("state-dir", "", "new directory for this executor's identity and storage")
state := fs.String("state-dir", "", "directory for this executor's identity and storage")
renew := fs.Bool("renew", false, "renew a drained managed executor certificate using a fresh setup token")
caFile := fs.String("ca-file", "", "trusted dispatcher CA certificate")
tokenFile := fs.String("token-file", "", "file containing the enrollment token")
capacity := fs.Int64("capacity", 10_000_000, "bandwidth offered to the dispatcher in bits per second")
Expand All @@ -77,7 +91,14 @@ func executorJoinCommand(ctx context.Context, args []string, options globalOptio
return code
}
if fs.NArg() != 0 || options.EndpointSet || options.Dispatcher != "" || strings.TrimSpace(*state) == "" {
return usageError("dbl executor join", executorJoinUsage, stderr, "supply --dispatcher, --executor and a new --state-dir after executor join")
return usageError("dbl executor join", executorJoinUsage, stderr, "supply --dispatcher, --executor and --state-dir after executor join")
}
changedSettings := false
fs.Visit(func(f *flag.Flag) {
changedSettings = changedSettings || f.Name == "capacity" || f.Name == "host-tag"
})
if *renew && changedSettings {
return usageError("dbl executor join", executorJoinUsage, stderr, "--renew preserves capacity and host tags; omit their flags")
}
if *capacity <= 0 {
return usageError("dbl executor join", executorJoinUsage, stderr, "--capacity must be positive bits per second")
Expand Down Expand Up @@ -108,7 +129,20 @@ func executorJoinCommand(ctx context.Context, args []string, options globalOptio
transport.TLSClientConfig.RootCAs = roots
}
statePath, err := filepath.Abs(*state)
if err == nil {
var installer *service.Installer
var retained service.Profile
if err == nil && *renew {
var code int
var ok bool
installer, code, ok = newInstaller("dbl executor join", serviceOptions{}, deps, stderr)
if !ok {
return code
}
retained, err = installer.CheckCertificateRenewal(ctx, filepath.Base(statePath))
if err == nil && (retained.StateDir != statePath || retained.ExecutorID != *id) {
err = errors.New("--renew must name the installed executor ID and its canonical managed state directory")
}
} else if err == nil {
_, err = os.Lstat(statePath)
if err == nil {
err = fmt.Errorf("state directory already exists: %s; keep existing identities and choose a new directory", statePath)
Expand Down Expand Up @@ -138,6 +172,17 @@ func executorJoinCommand(ctx context.Context, args []string, options globalOptio
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("enrollment redirects are not followed; use the dispatcher's HTTPS API URL")
}}
if *renew {
if err := renewExecutorCertificate(ctx, installer, retained, client, joinURL, token); err != nil {
return reportFailure(ctx, "dbl executor join", stderr, err)
}
return emitReported(ctx, "dbl executor join", options.Output, stdout, stderr, map[string]string{
"executor_id": *id, "state_dir": statePath, "operation": "renewed",
}, func(w io.Writer) error {
_, err := fmt.Fprintf(w, "Executor %s certificate renewed. Key, configuration and stored work retained.\nThe service remains stopped and disabled. Resume explicitly:\n sudo %s drain --role executor --name %s --resume\n", *id, roleShellWord(executable), roleShellWord(retained.Name))
return err
})
}
if err := joinExecutor(ctx, client, joinURL, *id, statePath, token, hostTags, *capacity); err != nil {
return reportFailure(ctx, "dbl executor join", stderr, err)
}
Expand Down Expand Up @@ -302,38 +347,51 @@ func joinExecutor(ctx context.Context, client *http.Client, endpoint, id, state,
if err := storagecheck.BootstrapFresh(ctx, storagecheck.Executor, filepath.Join(state, "executor.sqlite")); err != nil {
return err
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: id}}, key)
result, err := requestExecutorCertificate(ctx, client, endpoint, id, token, key, keyPEM)
if err != nil {
return err
}
for _, file := range []struct{ name, data string }{{"executor.crt", result.CertificatePEM}, {"ca.crt", result.CAPEM}, {"service.toml", executorJoinConfig(id, state, result, hostTags, capacity)}} {
if err := writeJoinFile(filepath.Join(state, file.name), []byte(file.data)); err != nil {
return err
}
}
return nil
}

func requestExecutorCertificate(ctx context.Context, client *http.Client, endpoint, id, token string, key crypto.Signer, keyPEM []byte) (executorJoinResponse, error) {
var result executorJoinResponse
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: id}}, key)
if err != nil {
return result, err
}
body, err := json.Marshal(map[string]string{"executor_id": id, "token": token, "csr": string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr}))})
if err != nil {
return err
return result, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
return result, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Debuglet-API-Version", "1.10")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request executor enrollment: %w", err)
return result, fmt.Errorf("request executor enrollment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("dispatcher refused enrollment (HTTP %d); check the dispatcher URL and replace expired or used tokens in My nodes", resp.StatusCode)
return result, fmt.Errorf("dispatcher refused enrollment (HTTP %d); check the dispatcher URL and replace expired or used tokens in My nodes", resp.StatusCode)
}
var result executorJoinResponse
decoder := json.NewDecoder(io.LimitReader(resp.Body, 64*1024))
if err := decoder.Decode(&result); err != nil {
return fmt.Errorf("decode enrollment response: %w", err)
return result, fmt.Errorf("decode enrollment response: %w", err)
}
if decoder.Decode(new(any)) != io.EOF {
return errors.New("enrollment response contains unexpected data")
return result, errors.New("enrollment response contains unexpected data")
}
if err := validateEnrollmentCertificate(result, id, keyPEM); err != nil {
return err
return result, err
}
// The server can use a public certificate while issuing client identities
// from its own CA. Keep both authenticated trust roots for control TLS.
Expand All @@ -344,12 +402,7 @@ func joinExecutor(ctx context.Context, client *http.Client, endpoint, id, state,
}
}
}
for _, file := range []struct{ name, data string }{{"executor.crt", result.CertificatePEM}, {"ca.crt", result.CAPEM}, {"service.toml", executorJoinConfig(id, state, result, hostTags, capacity)}} {
if err := writeJoinFile(filepath.Join(state, file.name), []byte(file.data)); err != nil {
return err
}
}
return nil
return result, nil
}

func validateEnrollmentCertificate(result executorJoinResponse, id string, keyPEM []byte) error {
Expand Down
48 changes: 48 additions & 0 deletions cmd/dbl/executor_join_renew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 ETH Zurich

package main

import (
"context"
"crypto"
"crypto/tls"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"

"github.com/netsec-ethz/debuglet/internal/demo/service"
)

func renewExecutorCertificate(ctx context.Context, installer *service.Installer, p service.Profile, client *http.Client, endpoint, token string) (err error) {
keyPEM, err := os.ReadFile(filepath.Join(p.StateDir, "executor.key"))
if err != nil {
return err
}
pair, err := tls.LoadX509KeyPair(filepath.Join(p.StateDir, "executor.crt"), filepath.Join(p.StateDir, "executor.key"))
if err != nil {
return err
}
key, ok := pair.PrivateKey.(crypto.Signer)
if !ok {
return errors.New("retained executor key cannot sign a certificate request")
}
defer func() {
if err != nil {
err = fmt.Errorf("%w; keep the service stopped. If enrollment was sent, obtain a fresh Set up again token and repeat the same --renew command; do not delete or re-enroll the state directory", err)
}
}()
result, err := requestExecutorCertificate(ctx, client, endpoint, p.ExecutorID, token, key, keyPEM)
if err != nil {
return err
}
if result.GRPCAddress != p.DispatcherGRPC || result.YamuxAddress != p.DispatcherHTTP {
return errors.New("renewal returned different native endpoints; ask the service operator for recovery instructions")
}
if err := installer.RenewCertificate(ctx, p.Name, []byte(result.CertificatePEM)); err != nil {
return fmt.Errorf("publish renewed certificate under retained native trust: %w", err)
}
return nil
}
Loading