From 54c9105be4b4c667c60a7354d963eec797738bbb Mon Sep 17 00:00:00 2001 From: Milo Casagrande Date: Tue, 23 Jun 2026 11:33:17 +0200 Subject: [PATCH 1/2] feat: local dev setup with mock data Add dev-only path to run the server on localhost with a populated UI, without real devices. - cmd/seed: seeds N mock devices. - contrib/run-local.sh: builds, generate a self-signed cert to satisfy the gateway, runs auth-init --test (noauth), seeds, then starts. --auth flag inits local auth and seeds an initial user (admin/admin, env-overridable via AUTH_USER/AUTH_PASS) - docs/run-locally.md: command guide. Signed-off-by: Milo Casagrande --- cmd/seed/main.go | 153 +++++++++++++++++++ cmd/seed/main_test.go | 31 ++++ cmd/seed/updates.go | 289 +++++++++++++++++++++++++++++++++++ cmd/seed/updates_test.go | 78 ++++++++++ cmd/server/auth_init.go | 14 +- cmd/server/auth_init_test.go | 29 ++++ contrib/run-local.sh | 71 +++++++++ docs/run-locally.md | 86 +++++++++++ 8 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 cmd/seed/main.go create mode 100644 cmd/seed/main_test.go create mode 100644 cmd/seed/updates.go create mode 100644 cmd/seed/updates_test.go create mode 100644 cmd/server/auth_init_test.go create mode 100755 contrib/run-local.sh create mode 100644 docs/run-locally.md diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 00000000..ed93a91a --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,153 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/foundriesio/update-server/storage" + "github.com/foundriesio/update-server/storage/api" + "github.com/foundriesio/update-server/storage/gateway" +) + +// dummyPubKey is a hardcoded RSA public key PEM block. It is only displayed in +// the UI and is never verified by the seed tool. +const dummyPubKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a2rwplBQLzHPZe5TNJG +O9pQBXaLqRGS4KMQpQs3wMYNg7guAlT7xHGQNpBsXhTNkqMFGbHLK3XFI+djNBKD +4nlYRjMVDMGUCVKHmXpRxMIq6N1hIBfJrAXtP9iBNV6eXB2n0j7mYwXzZvRpPoD9 +7BFIL8A2RmaXYYSSGFOZBJqfIIQgIdAoaajsGfkf2JIQN0KlzJIVVgvA3JaVbG3T +LRm4kXgBiH47vkJC8M7oYpj3KZS8VaVFCpWVgkIVtMNh3qqDC9gMjOq3hQcVU6UR +YoEdwHGJ3jVQYVt5M3Z5bkqxZ0n8LxFSjuE7pqQqJKLmXZuIF1RZKoHb7pmJWxkv +LQIDAQAB +-----END PUBLIC KEY-----` + +var groups = []string{"alpha", "beta", "gamma", "delta", "epsilon"} + +// openStorage opens the filesystem, database, gateway, and API storage handles +// for the given datadir. It is shared by seedDevices and seedUpdates. +func openStorage(datadir string) (*storage.FsHandle, *api.Storage, *gateway.Storage, error) { + fs, err := storage.NewFs(datadir) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load filesystem: %w", err) + } + db, err := storage.NewDb(fs.Config.DbFile()) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load database: %w", err) + } + gw, err := gateway.NewStorage(db, fs) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to open gateway storage: %w", err) + } + ap, err := api.NewStorage(db, fs) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to open api storage: %w", err) + } + return fs, ap, gw, nil +} + +func seedDevices(datadir string, numDevices int) error { + _, ap, gw, err := openStorage(datadir) + if err != nil { + return err + } + + created := 0 + skipped := 0 + + for i := 1; i <= numDevices; i++ { + uuid := fmt.Sprintf("seed-device-%05d", i) + + // Check if device already exists; skip creation if so. + existing, err := gw.DeviceGet(uuid) + if err != nil { + return fmt.Errorf("DeviceGet(%s): %w", uuid, err) + } + var d *gateway.Device + if existing != nil { + log.Printf("skip %s (already exists)", uuid) + skipped++ + d = existing + } else { + d, err = gw.DeviceCreate(uuid, dummyPubKey) + if err != nil { + return fmt.Errorf("DeviceCreate(%s): %w", uuid, err) + } + created++ + log.Printf("create %s", uuid) + } + + targetName := fmt.Sprintf("intel-corei7-64-lmp-%d", 100+i) + ostreeHash := fmt.Sprintf("%064x", i*0xdeadbeef) + if err := d.CheckIn(targetName, "main", ostreeHash, "shellhttpd,nginx"); err != nil { + return fmt.Errorf("CheckIn(%s): %w", uuid, err) + } + + hwInfo := fmt.Sprintf(`{"hwId":"intel-corei7-64","serial":"SN-%05d","machine":"seed"}`, i) + if err := d.PutFile(storage.HwInfoFile, hwInfo); err != nil { + return fmt.Errorf("PutFile(hw-info, %s): %w", uuid, err) + } + + netInfo := fmt.Sprintf(`{"hostname":"%s","local_ipv4":"192.168.1.%d","mac":"de:ad:be:ef:00:%02x"}`, + uuid, 100+i, i) + if err := d.PutFile(storage.NetInfoFile, netInfo); err != nil { + return fmt.Errorf("PutFile(net-info, %s): %w", uuid, err) + } + + name := fmt.Sprintf("seed-device-%05d", i) + group := groups[(i-1)%len(groups)] + namePtr := name + groupPtr := group + if err := ap.PatchDeviceLabels( + map[string]*string{"name": &namePtr, "group": &groupPtr}, + []string{uuid}, + ); err != nil { + return fmt.Errorf("PatchDeviceLabels(%s): %w", uuid, err) + } + + tomlConfig := fmt.Sprintf(`[device] + uuid = "%s" + tag = "main" + +[pacman] + type = "ostree+compose_apps" +`, uuid) + if err := ap.SaveDeviceConfig(uuid, tomlConfig, "noauth-fake-user", "seed"); err != nil { + return fmt.Errorf("SaveDeviceConfig(%s): %w", uuid, err) + } + } + + fmt.Printf("seed complete: %d created, %d skipped (total requested: %d)\n", created, skipped, numDevices) + return nil +} + +func main() { + datadir := flag.String("datadir", "", "path to the server data directory (required)") + numDevices := flag.Int("devices", 5, "number of devices to seed") + numUpdates := flag.Int("updates", 2, "number of fake updates to seed") + flag.Parse() + + if *datadir == "" { + fmt.Fprintln(os.Stderr, "error: --datadir is required") + flag.Usage() + os.Exit(1) + } + + if err := seedDevices(*datadir, *numDevices); err != nil { + log.Fatalf("seed devices failed: %v", err) + } + + if *numUpdates > 0 { + fs, ap, _, err := openStorage(*datadir) + if err != nil { + log.Fatalf("seed updates: open storage: %v", err) + } + if err := seedUpdates(fs, ap, *numUpdates); err != nil { + log.Fatalf("seed updates failed: %v", err) + } + } +} diff --git a/cmd/seed/main_test.go b/cmd/seed/main_test.go new file mode 100644 index 00000000..12bf575f --- /dev/null +++ b/cmd/seed/main_test.go @@ -0,0 +1,31 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package main + +import ( + "testing" + + "github.com/foundriesio/update-server/storage" + "github.com/foundriesio/update-server/storage/gateway" + "github.com/stretchr/testify/require" +) + +func TestSeed(t *testing.T) { + datadir := t.TempDir() + + err := seedDevices(datadir, 3) + require.NoError(t, err) + + // Verify the first device is retrievable via gateway DeviceGet. + fs, err := storage.NewFs(datadir) + require.NoError(t, err) + db, err := storage.NewDb(fs.Config.DbFile()) + require.NoError(t, err) + gw, err := gateway.NewStorage(db, fs) + require.NoError(t, err) + + d, err := gw.DeviceGet("seed-device-00001") + require.NoError(t, err) + require.NotNil(t, d, "expected seed-device-00001 to be present in the DB") +} diff --git a/cmd/seed/updates.go b/cmd/seed/updates.go new file mode 100644 index 00000000..c0f00b47 --- /dev/null +++ b/cmd/seed/updates.go @@ -0,0 +1,289 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package main + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/foundriesio/update-server/storage" + "github.com/foundriesio/update-server/storage/api" +) + +// updateRegistered reports whether (tag, name) is already in the updates table. +func updateRegistered(apiStorage *api.Storage, tag, name string) (bool, error) { + existing, err := apiStorage.ListUpdates(tag) + if err != nil { + return false, err + } + for _, u := range existing[tag] { + if u.Name == name { + return true, nil + } + } + return false, nil +} + +// fakeHex returns a deterministic lowercase hex string of the requested byte +// length based on the seed index i. It is not cryptographic — it only needs +// to look plausible in the UI. +func fakeHex(i, length int) string { + result := make([]byte, length) + for j := 0; j < length; j++ { + result[j] = byte(((i+1)*31 + j*17) & 0xff) + } + return fmt.Sprintf("%x", result) +} + +// targetsJSON builds a structurally-valid TUF targets.json body. +func targetsJSON(i int, name, expires string) (string, error) { + sha256 := fakeHex(i, 32) // 64 hex chars + sha512 := fakeHex(i+100, 64) // 128 hex chars + appSha1 := fakeHex(i+200, 32) // shellhttpd + appSha2 := fakeHex(i+300, 32) // nginx + keyid := fakeHex(i+400, 32) // 64 hex chars + sig := fakeHex(i+500, 64) // 128 hex chars + + now := time.Now().UTC().Format(time.RFC3339) + + type dockerApp struct { + URI string `json:"uri"` + } + type custom struct { + HardwareIDs []string `json:"hardwareIds"` + Tags []string `json:"tags"` + TargetFormat string `json:"targetFormat"` + Version string `json:"version"` + Name string `json:"name"` + URI string `json:"uri"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Arch string `json:"arch"` + DockerComposeApps map[string]dockerApp `json:"docker_compose_apps"` + } + type targetEntry struct { + Length int `json:"length"` + Hashes map[string]string `json:"hashes"` + Custom custom `json:"custom"` + } + type signed struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int `json:"version"` + Expires string `json:"expires"` + Targets map[string]targetEntry `json:"targets"` + } + type signature struct { + KeyID string `json:"keyid"` + Method string `json:"method"` + Sig string `json:"sig"` + } + type tufTargets struct { + Signed signed `json:"signed"` + Signatures []signature `json:"signatures"` + } + + targetName := fmt.Sprintf("intel-corei7-64-lmp-%s", name) + + doc := tufTargets{ + Signed: signed{ + Type: "Targets", + SpecVersion: "1.0", + Version: 148 + i, + Expires: expires, + Targets: map[string]targetEntry{ + targetName: { + Length: 0, + Hashes: map[string]string{ + "sha256": sha256, + "sha512": sha512, + }, + Custom: custom{ + HardwareIDs: []string{"intel-corei7-64"}, + Tags: []string{"main"}, + TargetFormat: "OSTREE", + Version: name, + Name: "intel-corei7-64-lmp", + URI: "", + CreatedAt: now, + UpdatedAt: now, + Arch: "amd64", + DockerComposeApps: map[string]dockerApp{ + "shellhttpd": {URI: fmt.Sprintf("hub.foundries.io/local-factory/shellhttpd@sha256:%s", appSha1)}, + "nginx": {URI: fmt.Sprintf("hub.foundries.io/local-factory/nginx@sha256:%s", appSha2)}, + }, + }, + }, + }, + }, + Signatures: []signature{ + {KeyID: keyid, Method: "eddsa", Sig: sig}, + }, + } + + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} + +func snapshotJSON(i int, expires string) string { + keyid := fakeHex(i+600, 32) + sig := fakeHex(i+700, 64) + return fmt.Sprintf(`{ + "signed": { + "_type": "Snapshot", + "spec_version": "1.0", + "version": %d, + "expires": "%s", + "meta": { + "targets.json": {"version": %d, "length": 512, "hashes": {"sha256": "%s"}} + } + }, + "signatures": [{"keyid": "%s", "method": "eddsa", "sig": "%s"}] +}`, 148+i, expires, 148+i, fakeHex(i+800, 32), keyid, sig) +} + +func timestampJSON(i int, expires string) string { + keyid := fakeHex(i+900, 32) + sig := fakeHex(i+1000, 64) + return fmt.Sprintf(`{ + "signed": { + "_type": "Timestamp", + "spec_version": "1.0", + "version": %d, + "expires": "%s", + "meta": { + "snapshot.json": {"version": %d, "length": 256, "hashes": {"sha256": "%s"}} + } + }, + "signatures": [{"keyid": "%s", "method": "eddsa", "sig": "%s"}] +}`, 148+i, expires, 148+i, fakeHex(i+1100, 32), keyid, sig) +} + +func rootJSON(i int, expires string) string { + keyid := fakeHex(i+1200, 32) + sig := fakeHex(i+1300, 64) + pubkeyVal := fakeHex(i+1400, 32) + return fmt.Sprintf(`{ + "signed": { + "_type": "Root", + "spec_version": "1.0", + "version": 1, + "expires": "%s", + "consistent_snapshot": false, + "keys": { + "%s": { + "keytype": "ed25519", + "scheme": "ed25519", + "keyid_hash_algorithms": ["sha256","sha512"], + "keyval": {"public": "%s"} + } + }, + "roles": { + "root": {"keyids": ["%s"], "threshold": 1}, + "targets": {"keyids": ["%s"], "threshold": 1}, + "snapshot": {"keyids": ["%s"], "threshold": 1}, + "timestamp": {"keyids": ["%s"], "threshold": 1} + } + }, + "signatures": [{"keyid": "%s", "method": "eddsa", "sig": "%s"}] +}`, expires, keyid, pubkeyVal, keyid, keyid, keyid, keyid, keyid, sig) +} + +// seedUpdates creates `count` fake update entries (TUF metadata + token dirs + +// one rollout each) under /updates/main/. +func seedUpdates(fs *storage.FsHandle, apiStorage *api.Storage, count int) error { + const tag = "main" + const baseVersion = 148 + + expires := time.Now().AddDate(0, 6, 0).UTC().Format(time.RFC3339) + + created := 0 + skipped := 0 + + for i := 0; i < count; i++ { + name := fmt.Sprintf("%d", baseVersion+i) + + // --- TUF files --- + + targets, err := targetsJSON(i, name, expires) + if err != nil { + return fmt.Errorf("build targets.json for %s/%s: %w", tag, name, err) + } + if err := fs.Updates.Tuf.WriteFile(tag, name, "targets.json", targets); err != nil { + return fmt.Errorf("write targets.json for %s/%s: %w", tag, name, err) + } + if err := fs.Updates.Tuf.WriteFile(tag, name, "snapshot.json", snapshotJSON(i, expires)); err != nil { + return fmt.Errorf("write snapshot.json for %s/%s: %w", tag, name, err) + } + if err := fs.Updates.Tuf.WriteFile(tag, name, "timestamp.json", timestampJSON(i, expires)); err != nil { + return fmt.Errorf("write timestamp.json for %s/%s: %w", tag, name, err) + } + if err := fs.Updates.Tuf.WriteFile(tag, name, "1.root.json", rootJSON(i, expires)); err != nil { + return fmt.Errorf("write 1.root.json for %s/%s: %w", tag, name, err) + } + + // Register the update in the DB so it shows up in ListUpdates. + // ponytail: skip if already present to stay idempotent. + registered, err := updateRegistered(apiStorage, tag, name) + if err != nil { + return fmt.Errorf("list updates for %s/%s: %w", tag, name, err) + } + if !registered { + if err := apiStorage.InsertUpdate(tag, name, "seed"); err != nil { + return fmt.Errorf("insert update %s/%s: %w", tag, name, err) + } + } + + // --- Token files for ostree_repo and apps --- + + const ostreeConfig = "[core]\nrepo_version=1\nmode=archive-z2\n" + if err := fs.Updates.Ostree.WriteFile(tag, name, "config", ostreeConfig); err != nil { + return fmt.Errorf("write ostree config for %s/%s: %w", tag, name, err) + } + + const ociLayout = `{"imageLayoutVersion":"1.0.0"}` + if err := fs.Updates.Apps.WriteFile(tag, name, "oci-layout", ociLayout); err != nil { + return fmt.Errorf("write oci-layout for %s/%s: %w", tag, name, err) + } + const indexJSON = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}` + if err := fs.Updates.Apps.WriteFile(tag, name, "index.json", indexJSON); err != nil { + return fmt.Errorf("write index.json for %s/%s: %w", tag, name, err) + } + + // --- Rollout (idempotent) --- + + const rolloutName = "seed-rollout" + existing, err := fs.Updates.Rollouts.ListFiles(tag, name) + if err != nil { + return fmt.Errorf("list rollouts for %s/%s: %w", tag, name, err) + } + rolloutExists := false + for _, f := range existing { + if f == rolloutName { + rolloutExists = true + break + } + } + if rolloutExists { + log.Printf("skip rollout %s/%s/%s (already exists)", tag, name, rolloutName) + skipped++ + } else { + if err := apiStorage.CreateRollout(tag, name, rolloutName, api.Rollout{ + Groups: []string{"alpha"}, + }); err != nil { + return fmt.Errorf("CreateRollout for %s/%s: %w", tag, name, err) + } + log.Printf("create update %s/%s + rollout %s", tag, name, rolloutName) + created++ + } + } + + fmt.Printf("updates seed complete: %d created, %d skipped (total requested: %d)\n", created, skipped, count) + return nil +} diff --git a/cmd/seed/updates_test.go b/cmd/seed/updates_test.go new file mode 100644 index 00000000..8d7fbb21 --- /dev/null +++ b/cmd/seed/updates_test.go @@ -0,0 +1,78 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package main + +import ( + "encoding/json" + "testing" + + "github.com/foundriesio/update-server/storage" + "github.com/foundriesio/update-server/storage/api" + "github.com/stretchr/testify/require" +) + +func TestSeedUpdates(t *testing.T) { + datadir := t.TempDir() + + fs, err := storage.NewFs(datadir) + require.NoError(t, err) + db, err := storage.NewDb(fs.Config.DbFile()) + require.NoError(t, err) + apiStorage, err := api.NewStorage(db, fs) + require.NoError(t, err) + + err = seedUpdates(fs, apiStorage, 2) + require.NoError(t, err) + + // Verify both updates are listed. + updates, err := apiStorage.ListUpdates("main") + require.NoError(t, err) + require.Contains(t, updates, "main", "expected 'main' tag in updates map") + names := make([]string, 0, len(updates["main"])) + for _, u := range updates["main"] { + names = append(names, u.Name) + } + require.Contains(t, names, "148", "expected update '148' under 'main'") + require.Contains(t, names, "149", "expected update '149' under 'main'") + + // Verify GetUpdateTufMetadata returns parseable data for update "148". + meta, err := apiStorage.GetUpdateTufMetadata("main", "148") + require.NoError(t, err) + require.Contains(t, meta, "targets.json", "TUF metadata missing targets.json") + require.Contains(t, meta, "snapshot.json", "TUF metadata missing snapshot.json") + require.Contains(t, meta, "timestamp.json", "TUF metadata missing timestamp.json") + require.Contains(t, meta, "root.json", "TUF metadata missing root.json") + + // Parse targets.json and verify target name, tag, and version. + targetsRaw := meta["targets.json"] + targetsBytes, err := json.Marshal(targetsRaw) + require.NoError(t, err) + + var targets struct { + Signed struct { + Targets map[string]struct { + Custom struct { + Tags []string `json:"tags"` + Version string `json:"version"` + } `json:"custom"` + } `json:"targets"` + } `json:"signed"` + } + require.NoError(t, json.Unmarshal(targetsBytes, &targets)) + + const targetName = "intel-corei7-64-lmp-148" + target, ok := targets.Signed.Targets[targetName] + require.True(t, ok, "expected target %q in targets.json", targetName) + require.Contains(t, target.Custom.Tags, "main", "target tags must include 'main'") + require.Equal(t, "148", target.Custom.Version, "target version must be the numeric string '148'") + + // Verify rollout was created. + rollouts, err := apiStorage.ListRollouts("main", "148") + require.NoError(t, err) + require.Contains(t, rollouts, "seed-rollout", "expected seed-rollout to be created for update 148") + + // Verify idempotency: seed again and ensure we get 0 created, 2 skipped. + err = seedUpdates(fs, apiStorage, 2) + require.NoError(t, err, "second seedUpdates call should not fail (idempotent)") +} diff --git a/cmd/server/auth_init.go b/cmd/server/auth_init.go index 8a26e644..d118b8c0 100644 --- a/cmd/server/auth_init.go +++ b/cmd/server/auth_init.go @@ -4,12 +4,15 @@ package main import ( + "encoding/json" + "github.com/foundriesio/update-server/storage" "github.com/foundriesio/update-server/storage/users" ) type AuthInitCmd struct { - Test bool `help:"Initialize auth with test config: full access for everyone"` + Test bool `help:"Initialize auth with test config: full access for everyone"` + Local bool `help:"Initialize auth with local username/password (relaxed rules, dev only)"` } func (c AuthInitCmd) Run(args CommonArgs) error { @@ -17,6 +20,15 @@ func (c AuthInitCmd) Run(args CommonArgs) error { return err } else if err = fs.Auth.InitHmacSecret(); err != nil { return err + } else if c.Local { + cfg := storage.AuthConfig{ + Type: "local", + NewUserDefaultScopes: users.ScopesAvailable(), + Config: json.RawMessage(`{"MinPasswordLength":0,"PasswordHistory":0,"PasswordAgeDays":0,` + + `"PasswordComplexityRules":{"RequireUppercase":false,"RequireLowercase":false,` + + `"RequireDigit":false,"RequireSpecialChar":""}}`), + } + return fs.Auth.SaveAuthConfig(cfg) } else if c.Test { cfg := storage.AuthConfig{ Type: "noauth", diff --git a/cmd/server/auth_init_test.go b/cmd/server/auth_init_test.go new file mode 100644 index 00000000..e5081bfc --- /dev/null +++ b/cmd/server/auth_init_test.go @@ -0,0 +1,29 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/foundriesio/update-server/storage" +) + +func TestAuthInitLocal(t *testing.T) { + tmpDir := t.TempDir() + + cmd := AuthInitCmd{Local: true} + common := CommonArgs{DataDir: tmpDir} + require.Nil(t, cmd.Run(common)) + + fs, err := storage.NewFs(tmpDir) + require.Nil(t, err) + + cfg, err := fs.Auth.GetAuthConfig() + require.Nil(t, err) + require.Equal(t, "local", cfg.Type) + require.Greater(t, len(cfg.NewUserDefaultScopes), 0) + require.Greater(t, len(cfg.Config), 0) +} diff --git a/contrib/run-local.sh b/contrib/run-local.sh new file mode 100755 index 00000000..06dbce71 --- /dev/null +++ b/contrib/run-local.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause-Clear + +set -euo pipefail + +DATADIR="./.local-data" +USE_AUTH=0 +AUTH_USER="${AUTH_USER:-admin}" +AUTH_PASS="${AUTH_PASS:-admin}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --auth) + USE_AUTH=1 + shift + ;; + -*) + echo "Unknown flag: $1" >&2 + exit 1 + ;; + *) + DATADIR="$1" + shift + ;; + esac +done + +echo "==> Building fioserver..." +go build -o bin/fioserver ./cmd/server + +echo "==> Preparing data directory: $DATADIR" +mkdir -p "$DATADIR/certs" + +if [ ! -f "$DATADIR/certs/tls.pem" ]; then + echo "==> Generating self-signed TLS certificate (dev only)..." + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ + -keyout "$DATADIR/certs/tls.key" \ + -out "$DATADIR/certs/tls.pem" \ + -days 3650 \ + -subj "/CN=localhost" + cp "$DATADIR/certs/tls.pem" "$DATADIR/certs/cas.pem" +fi + +if [ "$USE_AUTH" -eq 1 ]; then + echo "==> Initialising auth (local username/password mode)..." + ./bin/fioserver --datadir "$DATADIR" auth-init --local +else + echo "==> Initialising auth (noauth / test mode)..." + ./bin/fioserver --datadir "$DATADIR" auth-init --test +fi + +echo "==> Seeding mock devices..." +go run ./cmd/seed --datadir "$DATADIR" + +if [ "$USE_AUTH" -eq 1 ]; then + echo "==> Creating initial user: $AUTH_USER" + ./bin/fioserver --datadir "$DATADIR" user-add --username "$AUTH_USER" --password "$AUTH_PASS" \ + || echo "(user already exists, skipping)" + + echo "" + echo "UI: http://localhost:8080/devices" + echo "Login: $AUTH_USER / $AUTH_PASS" + echo "" +else + echo "" + echo "UI: http://localhost:8080/devices (noauth — no login required)" + echo "" +fi + +exec ./bin/fioserver --datadir "$DATADIR" serve diff --git a/docs/run-locally.md b/docs/run-locally.md new file mode 100644 index 00000000..a1115e62 --- /dev/null +++ b/docs/run-locally.md @@ -0,0 +1,86 @@ +# Run Locally with Mock Data + +Bring the server up on `http://localhost:8080` with a populated UI, no Factory +PKI, and no real devices. Intended for local development and UI exploration only. + +## Prerequisites + +- **Go** — to build and run the server and seed tool +- **openssl** — to generate a throwaway self-signed certificate +- **git-lfs** — the web UI's Pico CSS asset is stored in Git LFS; run + `git lfs pull` if the UI looks unstyled + +## Quick start + +``` +./contrib/run-local.sh [datadir] +``` + +`datadir` defaults to `./.local-data`. + +## What it does + +- Builds `bin/fioserver` from source. +- Creates a self-signed EC P-256 certificate valid for 10 years with + `CN=localhost`. The same certificate is reused as the device CA (`cas.pem`) + so the gateway starts without real Factory PKI. **Dev-only — do not use in + production.** +- Runs `auth-init --test` to configure the `noauth` provider (no login + required) and generate an HMAC session key. +- Seeds 5 mock devices with display names, groups, hardware info, and a device + config via `go run ./cmd/seed`. +- Starts `fioserver serve`, binding the UI on `:8080` and the device gateway + on `:8443`. + +## Browse the UI + +``` +http://localhost:8080/devices +``` + +No login is required. The `noauth` provider grants full access automatically. + +## Running with login (local auth) + +Pass `--auth` to start the server with the `local` username/password provider +instead of `noauth`: + +``` +./contrib/run-local.sh --auth [datadir] +``` + +This seeds an initial user (`admin` / `admin` by default) and prints the login +URL at startup. **Dev-only — do not use in production.** + +Override the credentials via environment variables: + +``` +AUTH_USER=myuser AUTH_PASS=mypassword ./contrib/run-local.sh --auth +``` + +Note: re-running against an existing `datadir` will fail at `auth-init` because +the HMAC secret already exists. Remove the data directory first: + +``` +rm -rf ./.local-data +``` + +## Reset + +Remove the data directory to start fresh: + +``` +rm -rf ./.local-data +``` + +## Known limitations + +- **Seeded updates are synthetic.** The seed tool creates N fake updates under + the `main` tag (default: 2, named `148`, `149`, …) with structurally valid TUF + metadata and a sample rollout targeting the `alpha` group. The `ostree_repo/` + and `apps/` directories are token stubs — no real device can pull from them. + See the [updates guide](./updates.md) for adding real update content. +- **The gateway mTLS port (`:8443`) will not accept real devices.** The + self-signed certificate is not trusted by actual FoundriesFactory devices. + Follow the [Quick Start](./quick-start.md) guide for a production-grade PKI + setup. From 02441aa165c69613651e1b636bfc9ada013cb456 Mon Sep 17 00:00:00 2001 From: Milo Casagrande Date: Tue, 23 Jun 2026 14:12:30 +0200 Subject: [PATCH 2/2] fix: add .local-data to .gitignore Signed-off-by: Milo Casagrande --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5f48e48c..e75a8c38 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ docs/swagger/* bin/* release/* +.local-data