diff --git a/.gitignore b/.gitignore index 9e8f10f855..9a08aaea9f 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ Fleet\ Desktop.app # custom golangci-lint executable custom-gcl /fleet + +# dibble (the seed slinger) — binary lives next to source +tools/dibble/dibble diff --git a/Makefile b/Makefile index 75d780f90e..de9019db1a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean clean-assets e2e-reset-db e2e-serve e2e-setup changelog db-reset db-backup db-restore check-go-cloner update-go-cloner check-no-testing-in-prod help +.PHONY: build clean clean-assets e2e-reset-db e2e-serve e2e-setup changelog db-reset db-backup db-restore check-go-cloner update-go-cloner check-no-testing-in-prod dibble help export GO111MODULE=on @@ -134,6 +134,11 @@ fdm: sudo ln -sf "$$(pwd)/build/fdm" /usr/local/bin/fdm; \ fi +.help-short--dibble: + @echo "Builds the dibble test-data seeder (binary lands at tools/dibble/dibble)" +dibble: + cd tools/dibble && go build -o dibble ./cmd/dibble + .help-short--serve: @echo "Start the fleet server" .help-short--up: diff --git a/tools/README.md b/tools/README.md index 969193c514..21b712b26a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -40,6 +40,9 @@ For detailed usage of specific tools: # Start test osqueryd instances cd tools/osquery && docker-compose up +# Seed test data (users/teams/policies/profiles/etc.) — see tools/dibble/README.md +make dibble && ./tools/dibble/dibble + # Serve files locally go run ./tools/file-server 8081 ./my-files @@ -255,7 +258,7 @@ go run ./tools/run-scripts -scripts-disabled -content 'echo "Test"' | `mdm/apple/applebmapi/` | Query Apple Business API | `go run ./tools/mdm/apple/applebmapi -mysql localhost:3306 -server-private-key -org-name ` | | `mdm/apple/appmanifest/` | Generate app manifest XML from .pkg | `go run ./tools/mdm/apple/appmanifest -pkg-file app.pkg -pkg-url https://example.com/app.pkg` | | `mdm/apple/apnspush/` | Send APNS push to enrolled devices | `go run ./tools/mdm/apple/apnspush -mysql localhost:3306 -server-private-key ` | -| `mdm/apple/loadtest/` | MDM load testing | `go run ./tools/mdm/apple/loadtest` | +| `mdm/apple/loadtest/` | MDM load testing (deprecated — see [dibble](dibble/README.md) for seeding teams + profiles) | `go run ./tools/mdm/apple/loadtest` | | `mdm/apple/macos-vm-auto-enroll/` | Auto-enroll macOS VMs in MDM | `./tools/mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh` | | `mdm/apple/setupexperience/` | Test setup experience flows | `go run ./tools/mdm/apple/setupexperience` | | `mdm/assets/` | Export/import MDM assets (SCEP, APNS, etc.) | `go run ./tools/mdm/assets export -key= -dir=` or `import` | @@ -281,7 +284,7 @@ go run ./tools/run-scripts -scripts-disabled -content 'echo "Test"' | `run-scripts/` | Test Orbit script execution | `go run ./tools/run-scripts -exec-id -content 'echo "Hello"'` | | **Packaging & Installers** | | | | `bomutils-docker/` | Docker image for BOM utils (macOS pkg) | Docker build for BOM utilities | -| `team-builder/` | Bulk fleet (formerly "team") creation + installer generation | `./build_teams.sh -s teams.txt -u fleet.example.com` | +| `team-builder/` | Bulk fleet (formerly "team") creation + installer generation (deprecated for plain seeding — see [dibble](dibble/README.md)) | `./build_teams.sh -s teams.txt -u fleet.example.com` | | `wix-docker/` | Docker image for WiX (Windows MSI) | Docker build for WiX toolset | | **Release & Distribution** | | | | `fleetctl-docker/` | Docker image for fleetctl packaging | `docker run fleetdm/fleetctl package --type=pkg` | @@ -303,17 +306,18 @@ go run ./tools/run-scripts -scripts-disabled -content 'echo "Test"' | `nvd/` | NVD (National Vulnerability Database) tools | See [nvd/nvdvuln/README.md](nvd/nvdvuln/README.md) | | `software/icons/` | Software icon management | See [software/icons/README.md](software/icons/README.md) | | `software/packages/` | Software package utilities | See [software/packages/README.md](software/packages/README.md) | -| `software/vulnerabilities/` | Seed vulnerable software for dev | `go run ./tools/software/vulnerabilities/seed_vuln_data.go --ubuntu 1 --macos 1 --windows 1` | -| `software/vulnerabilities/performance_test/` | Vuln performance testing | See [software/vulnerabilities/performance_test/README.md](software/vulnerabilities/performance_test/README.md) | +| `software/vulnerabilities/` | Seed vulnerable software for dev (deprecated — use [dibble](dibble/README.md) `vulns`) | `go run ./tools/software/vulnerabilities/seed_vuln_data.go --ubuntu 1 --macos 1 --windows 1` | +| `software/vulnerabilities/performance_test/` | Vuln performance testing (`seeder/` deprecated — use [dibble](dibble/README.md) `vulns`) | See [software/vulnerabilities/performance_test/README.md](software/vulnerabilities/performance_test/README.md) | | `vex-parser/` | Parse OpenVEX documents | `go run ./tools/vex-parser ` | | **Testing & Load Testing** | | | | `desktop-rate-limit/` | Test Fleet Desktop rate limiting | `go run ./tools/desktop-rate-limit -fleet_url https://localhost:8080` | | `kubequery/` | Kubequery + Fleet config | `kubectl apply -f kubequery-fleet.yml` | -| `loadtest/fleetd_labels/` | Apply manual labels for load testing | `go run ./tools/loadtest/fleetd_labels` | +| `dibble/` | One-stop seeder: users, teams, policies, reports, labels, scripts, profiles, software, enroll-secrets, vulns | `make dibble && ./tools/dibble/dibble` | +| `loadtest/fleetd_labels/` | Apply manual labels for load testing (deprecated — use [dibble](dibble/README.md) `labels`) | `go run ./tools/loadtest/fleetd_labels` | | `loadtest/metrics/` | Collect & compare AWS CloudWatch metrics for load test environments | `./tools/loadtest/metrics/collect-metrics.sh --workspace ` — see [loadtest/metrics/README.md](loadtest/metrics/README.md) | | `loadtest/osquery/` | Load test osquery on macOS/Windows/Linux | See [loadtest/osquery/README.md](loadtest/osquery/README.md) | -| `loadtest/scripts_and_profiles/` | Load test scripts and profiles | `go run ./tools/loadtest/scripts_and_profiles` | -| `loadtest/unified_queue/` | Load test unified queue story | See [loadtest/unified_queue/README.md](loadtest/unified_queue/README.md) | +| `loadtest/scripts_and_profiles/` | Load test scripts and profiles (deprecated — use [dibble](dibble/README.md) `scripts` + `profiles`) | `go run ./tools/loadtest/scripts_and_profiles` | +| `loadtest/unified_queue/` | Load test unified queue story (see [dibble](dibble/README.md) for seeding the prerequisite software/scripts) | See [loadtest/unified_queue/README.md](loadtest/unified_queue/README.md) | | `osquery/` | Containerized osqueryd testing | `docker-compose up` - See [osquery/README.md](osquery/README.md) | | `osquery-testing/` | osquery integration tests | `docker-compose up` in directory | | `test-certs/` | Fake certificate chain for TLS testing | See [test-certs/README.md](test-certs/README.md) | diff --git a/tools/dibble/README.md b/tools/dibble/README.md new file mode 100644 index 0000000000..8135718356 --- /dev/null +++ b/tools/dibble/README.md @@ -0,0 +1,204 @@ +# dibble + +``` +⠀⠀⠀⠀⠀⣀⣀⣤⣤⣤⣤⣤⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⡆⠸⣿⣿⣿⣷⣶⣤⣄⣾⣷⡄⠀⠀⠀⠀⠀⠀ +⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⡀⠀⠀⠀ +⠀⣤⣤⣤⣈⡉⠛⢿⣿⣿⣿⣿⣿⡆⢸⣿⣿⣿⣿⣿⣿⣿⣿⣧⣽⣿⣷⣄⠀⠀ +⠀⢿⠿⣿⣿⣿⣷⣤⡈⢻⣿⣿⣿⣇⠈⣿⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣿⣿⡄⠀ +⠀⠈⠀⢸⣿⣿⣿⣿⠇⠀⠛⠛⠛⠋⠀⢻⣿⣿⡟⢉⠀⠀⠈⠙⠛⠿⠏⣿⣷⠀ +⠀⠀⢠⣿⣿⡿⠟⢁⡄⠀⠀⠀⠀⠀⠀⠈⣿⣿⡇⣾⡀⠀⠀⠀⠀⠀⠀⠸⠿⠀ +⠀⠀⠸⣿⣿⠀⢸⣿⣇⠀⠀⠀⠀⠀⠀⠀⢹⣿⡇⠸⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠙⠛⠃⠀⠛⠛⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠀⠙⠃⠀⠀⠀⠀⠀⠀⠀⠀ + +🌱 dibble — Fleet's seed slinger — Dibble the Tapir +``` + +> A dibble (n.) is a pointed wooden tool for poking holes in soil and planting +> seeds. This dibble is a pointed CLI for poking holes in your Fleet server and +> planting test data. + +`dibble` is the one-stop tool for populating a Fleet server with everything +you'd want for dev or testing — users, teams (fleets), policies, reports, +labels, scripts, MDM profiles, software, enroll secrets, certificate authorities, +and vulnerable software inventory. + +**Hosts are intentionally out of scope.** Use [`cmd/osquery-perf`](../../cmd/osquery-perf) +for those — duplicating its work here would be a waste of perfectly good seeds. +`dibble hosts` is a thin convenience around osquery-perf: it lists the fleets, +lets you pick one with a radio button, fetches that fleet's enroll secret, and +prints (or runs) the `go run cmd/osquery-perf/agent.go ...` invocation. +Ctrl-C is forwarded to the child so simulated hosts shut down cleanly. + +## Quickstart + +```bash +# Build +make dibble # writes ./tools/dibble/dibble + +# Run the wizard (recommended first time) +./tools/dibble/dibble + +# Or skip the wizard and use flags +./tools/dibble/dibble all \ + --fleet-url http://localhost:8080 \ + --api-token "$FLEET_API_TOKEN" +``` + +The wizard asks for any missing config (Fleet URL, API token), offers to save +it to `~/.dibble.yaml`, lets you pick a theme and which entities to seed, and +then plants the seeds. + +## Subcommands + +| Command | What it plants | +| ------------------ | -------------------------------------------------------- | +| `dibble all` | Everything below, with idempotent defaults | +| `dibble users` | Themed users with rotating roles (observer → admin) | +| `dibble teams` | Teams (aka fleets) with themed names | +| `dibble policies` | Global + per-team policies, mixed platforms | +| `dibble reports` | Saved reports (formerly "queries"), various intervals | +| `dibble labels` | Dynamic, query-based labels | +| `dibble scripts` | Saved scripts (.sh, .ps1, .zsh) global + per-team | +| `dibble profiles` | Apple `.mobileconfig` + Windows `.xml` MDM profiles | +| `dibble software` | Upload custom installers (2-3 per ext) + add Fleet-maintained apps. Subcommands: `all`, `custom`, `maintained`. | +| `dibble enroll-secrets` | Per-team enroll secrets — the credential fleetd uses to join a team. Distinct from "Fleet secrets" (secret variables). Global enroll secret is left alone. | +| `dibble cas` | Certificate Authorities (placeholder for now) | +| `dibble vulns` | Vulnerable software, written directly to MySQL | +| `dibble activities` | Fake activity rows, written directly to MySQL — **non-idempotent**, marked with `*` | +| `dibble idp` | IDP accounts (`mdm_idp_accounts`) for seeded users + round-robin host assignments (`host_mdm_idp_accounts`), written directly to MySQL | +| `dibble hosts` | Radio-pick a fleet, fetch its enroll secret, and print or run the matching `cmd/osquery-perf` command (Ctrl-C forwarded) | +| `dibble ping` | Sanity-check `--fleet-url` and `--api-token` | +| `dibble version` | Print version + signature line | + +Aliases: `dibble fleets` ↔ `dibble teams`, `dibble queries` ↔ `dibble reports`. + +## Configuration + +Three layers, highest precedence first: + +1. **CLI flags:** `--fleet-url`, `--api-token`, `--theme`, `--insecure`, `--suffix`, `--dry-run`, `-v` +2. **Environment:** `FLEET_URL`, `FLEET_API_TOKEN`, `DIBBLE_THEME` +3. **Config file:** `~/.dibble.yaml` (written by the wizard) + +Example `~/.dibble.yaml`: + +```yaml +fleet_url: https://localhost:8080 +api_token: abc123... +theme: mix +insecure: true # set when targeting a Fleet with a self-signed cert +``` + +> Pass `--insecure` (or set `insecure: true` in the config file) to skip TLS +> verification — same convention as `fleetctl --insecure`. The wizard offers +> this automatically when it can't validate the cert on first ping. + +### Re-seeding (avoiding "skipped" on repeat runs) + +dibble is idempotent: a name like *Heart of Gold* exists in Fleet after your +first run, so the second `dibble all` reports `0 created, N skipped` for +everything global (labels, policies, reports, …). To get fresh entries each +run, append a `--suffix`: + +```bash +./tools/dibble/dibble all --suffix auto # random 4-char tag per run +./tools/dibble/dibble all --suffix demo2 # explicit tag — useful for reruns +``` + +Names become *Heart of Gold (auto-b3f1)*, *Towel readiness check (demo2)*, +etc. Emails get the suffix as a `+tag` so they remain unique and valid. + +## Themes + +Each theme is a curated set of character names that get used for users, teams, +policies, software titles, labels, and scripts. Pick one with +`--theme ` or let `mix` interleave them all. + +| Theme | Display | +| ------------------ | -------------------------------------- | +| `mix` *(default)* | Interleave every theme | +| `hitchhikers` | Hitchhiker's Guide to the Galaxy | +| `goodplace` | The Good Place | +| `parksrec` | Parks and Recreation | +| `tng` | Star Trek: The Next Generation | +| `lotr` | The Lord of the Rings | +| `dbz` | Dragon Ball Z | +| `robin_williams` | Robin Williams characters | +| `ghibli` | Studio Ghibli | +| `cosmere` | Brandon Sanderson's Cosmere | +| `sailor_moon` | Sailor Moon | + +Adding a theme: drop a new file in `themes/` that calls `Register(Theme{...})` +from `init()`. The wizard, `--theme` flag, and `mix` blend pick it up automatically. + +## Legacy tools → dibble + +These older tools are deprecated in favor of dibble but **still on disk** — +each has a banner in its README pointing at the dibble equivalent. We'll +remove them once nothing references them. Use dibble for new work. + +| Old path | dibble equivalent | +| -------------------------------------------------------- | -------------------------------------------- | +| `tools/team-builder/` | `dibble teams --count N` | +| `tools/loadtest/fleetd_labels/` | `dibble labels --count N` | +| `tools/loadtest/scripts_and_profiles/` | `dibble scripts` + `dibble profiles` | +| `tools/loadtest/unified_queue/` | `dibble software` (enqueue path is a TODO) | +| `tools/mdm/apple/loadtest/` | `dibble teams` + `dibble profiles` | +| `tools/software/vulnerabilities/seed_data/` | `dibble vulns --macos N --ubuntu N --windows N` | +| `tools/software/vulnerabilities/performance_test/seeder/`| `dibble vulns --macos N --ubuntu N --windows N` (bulk mode) | +| `tools/seed_data/queries/` | `dibble reports --count N` | + +Out-of-scope and **not** absorbed (those tools still live in `tools/`): + +- `cmd/osquery-perf` — hosts (intentionally not replicated). +- `tools/loadtest/osquery` — osqueryd CPU/mem profiling shell scripts. +- `tools/mdm/assets` — encrypted-asset export/import (backup, not seeding). +- `tools/saml` — SimpleSAMLPHP fixture for SSO testing. + +## Design notes & known TODOs + +- **API-first.** Most seeders call the Fleet API as a bearer-authed client. + The exceptions are `dibble vulns` and `dibble activities`, which write + directly to MySQL — Fleet has no "create vulnerability" or "create + activity" endpoint by design. +- **Idempotent.** Re-running `dibble all` against an already-seeded Fleet + reports "skipped" rather than failing. **Exception:** `dibble activities` + is intentionally non-idempotent — every run inserts a fresh batch + prefixed with `*` and tagged with the current run id, so seeded rows are + obvious in the UI and don't conflate across runs. +- **Custom-package software upload** uploads a curated set of installer + fixtures (`.pkg`, `.deb`, `.msi`, `.exe`, `.rpm`, `.tar.gz`, `.ipa`) + bundled into the dibble binary. The `.msi` and `.exe` slots use + upstream-signed installers (python-manager, 7-Zip) so the Fleet osquery + agent installer is never surfaced as a custom software item. `vim.deb` + is excluded for size. +- **Fleet-maintained apps** are added via the catalog endpoint + (`/api/latest/fleet/software/fleet_maintained_apps`) — `dibble software + maintained` POSTs the first N entries the server returns. +- **Mock CA creation** also isn't wired up — placeholder until a mock CA + type lands in Fleet. +- **Manual labels and hosts** need a populated host inventory; dibble doesn't + spin up hosts (see osquery-perf). + +## Contributing + +dibble follows the standard Go project layout: + +``` +tools/dibble/ +├── cmd/dibble/ # main entry point (just calls command.Execute) +├── pkg/ +│ ├── command/ # cobra commands, client, wizard, logging +│ ├── seed/ # per-entity seed logic +│ └── themes/ # character/media datasets + tapir mascot +├── go.mod / go.sum # dibble has its own module +└── README.md +``` + +dibble is a standalone Go module (`github.com/fleetdm/fleet/v4/tools/dibble`) +so its dependencies don't bleed into the root Fleet `go.mod`. + +- Build: `make dibble` from the repo root, or `go build -o dibble ./cmd/dibble` from `tools/dibble`. +- Tests: `cd tools/dibble && go test ./...` +- Lint: `cd tools/dibble && golangci-lint run` (the repo-level `make lint-go-incremental` skips this module). diff --git a/tools/dibble/cmd/dibble/main.go b/tools/dibble/cmd/dibble/main.go new file mode 100644 index 0000000000..1ec4ff8beb --- /dev/null +++ b/tools/dibble/cmd/dibble/main.go @@ -0,0 +1,26 @@ +// Command dibble is Fleet's one-stop test data seeder. +// +// Run `dibble` with no arguments for an interactive wizard. Pass subcommands +// like `dibble users` or `dibble all` to script it. +// +// See tools/dibble/README.md for the full design. +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/command" +) + +func main() { + if err := command.Execute(os.Args[1:]); err != nil { + // reportErrors already wrote each seeder error to stderr; only + // surface errors from other paths (config, network, etc.) here. + if !errors.Is(err, command.ErrSeederFailed) { + fmt.Fprintln(os.Stderr, "dibble:", err) + } + os.Exit(1) + } +} diff --git a/tools/dibble/go.mod b/tools/dibble/go.mod new file mode 100644 index 0000000000..c521850234 --- /dev/null +++ b/tools/dibble/go.mod @@ -0,0 +1,109 @@ +module github.com/fleetdm/fleet/v4/tools/dibble + +go 1.26.3 + +// The parent fleet module is included so we can enumerate every activity +// type for the `dibble activities` seeder. Pinned to the local checkout via +// the replace below — dibble is only ever built from inside this repo. +replace github.com/fleetdm/fleet/v4 => ../.. + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/fleetdm/fleet/v4 v4.0.0-00010101000000-000000000000 + github.com/go-sql-driver/mysql v1.10.0 + github.com/google/uuid v1.6.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/andygrunwald/go-jira v1.16.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/smithy-go v1.24.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-kit/kit v0.12.0 // indirect + github.com/go-kit/log v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/gomodule/oauth1 v0.2.0 // indirect + github.com/gomodule/redigo v1.8.9 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/igm/sockjs-go/v3 v3.0.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87 // indirect + github.com/nukosuke/go-zendesk v0.13.1 // indirect + github.com/oschwald/geoip2-golang v1.8.0 // indirect + github.com/oschwald/maxminddb-golang v1.10.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rs/zerolog v1.32.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/trivago/tgo v1.0.7 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/log v0.16.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/image v0.38.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/api v0.269.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/guregu/null.v3 v3.5.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + howett.net/plist v1.0.1 // indirect +) diff --git a/tools/dibble/go.sum b/tools/dibble/go.sum new file mode 100644 index 0000000000..62f1ab51c4 --- /dev/null +++ b/tools/dibble/go.sum @@ -0,0 +1,388 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/WatchBeam/clock v0.0.0-20170901150240-b08e6b4da7ea h1:C9Xwp9fZf9BFJMsTqs8P+4PETXwJPUOuJZwBfVci+4A= +github.com/WatchBeam/clock v0.0.0-20170901150240-b08e6b4da7ea/go.mod h1:N5eJIl14rhNCrE5I3O10HIyhZ1HpjaRHT9WDg1eXxtI= +github.com/andygrunwald/go-jira v1.16.0 h1:PU7C7Fkk5L96JvPc6vDVIrd99vdPnYudHu4ju2c2ikQ= +github.com/andygrunwald/go-jira v1.16.0/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7 h1:rDNxf0CQboBMqzm6WmhGL58pYpKMjU6Qs3/BfY3Em4Y= +github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7/go.mod h1:E1yDRkUMwlVGmDYcu5UJuwfznGNuVW29sjr2xxM2Y0w= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7toE8MWZ9U8n2dqRtX+RE= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w= +github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5 h1:HWN7xwaV7Zwrn3Jlauio4u4aTMFgRzG2fblHWQeir/k= +github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5/go.mod h1:6HBXRyFFqOw+ALkJ6YGHfrr20/YXYv6X9pcZErXRvCA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 h1:HD6R8K10gPbN9CNqRDOs42QombXlYeLOr4KkIxe2lQs= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8/go.mod h1:x66GdH8qjYTr6Kb4ik38Ewl6moLsg8igbceNsmxVxeA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/elastic/go-sysinfo v1.11.2 h1:mcm4OSYVMyws6+n2HIVMGkln5HOpo5Ie1ZmbbNn0jg4= +github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJa7AfT8HpBFQ= +github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= +github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0= +github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= +github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/oauth1 v0.2.0 h1:/nNHAD99yipOEspQFbAnNmwGTZ1UNXiD/+JLxwx79fo= +github.com/gomodule/oauth1 v0.2.0/go.mod h1:4r/a8/3RkhMBxJQWL5qzbOEcaQmNPIkNoI7P8sXeI08= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/groob/finalizer v0.0.0-20170707115354-4c2ed49aabda h1:5ikpG9mYCMFiZX0nkxoV6aU2IpCHPdws3gCNgdZeEV0= +github.com/groob/finalizer v0.0.0-20170707115354-4c2ed49aabda/go.mod h1:MyndkAZd5rUMdNogn35MWXBX1UiBigrU8eTj8DoAC2c= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE= +github.com/igm/sockjs-go/v3 v3.0.2/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/micromdm/micromdm v1.9.0 h1:FAsIKOpnGcq21UQCrHCUxZwSW4NwBLGOoUtzbURxds8= +github.com/micromdm/micromdm v1.9.0/go.mod h1:YsAtsEvfEIwpjYTUPpWkJXSfH0hhp9mMHW1BgIZgRt8= +github.com/micromdm/nanolib v0.2.0 h1:g5GHQuUpS82WIAB15LyenjF/0/WSUNJMe5XZfCJSXq4= +github.com/micromdm/nanolib v0.2.0/go.mod h1:FwBKCvvphgYvbdUZ+qw5kay7NHJcg6zPi8W7kXNajmE= +github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87 h1:U9A+0ZED3cPxb5ufiTzyn2kyo6UFoR5bMggCR0Q/DOg= +github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87/go.mod h1:flkfm0od6GzyXBqI28h5sgEyi3iPO28W2t1Zm9LpwWs= +github.com/mna/redisc v1.3.2 h1:sc9C+nj6qmrTFnsXb70xkjAHpXKtjjBuE6v2UcQV0ZE= +github.com/mna/redisc v1.3.2/go.mod h1:CplIoaSTDi5h9icnj4FLbRgHoNKCHDNJDVRztWDGeSQ= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nukosuke/go-zendesk v0.13.1 h1:EdYpn+FxROLguADEJK5reOHcpysM8wyWPOWO96SIc0A= +github.com/nukosuke/go-zendesk v0.13.1/go.mod h1:86Cg7RhSvPfOqZOtQXteJEV9yIQVQsy2HVDk++Yf3jA= +github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs= +github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw= +github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg= +github.com/oschwald/maxminddb-golang v1.10.0/go.mod h1:Y2ELenReaLAZ0b400URyGwvYxHV1dLIxBuyOsyYjHK0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa h1:FtxzVccOwaK+bK4bnWBPGua0FpCOhrVyeo6Fy9nxdlo= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa/go.mod h1:SoUAr/4M46rZ3WaLstHxGhLEgoYIDRqxQEXLOmOEB0Y= +github.com/smallstep/scep v0.0.0-20240214080410-892e41795b99 h1:e85HuLX5/MW15yJ7yWb/PMNFW1Kx1N+DeQtpQnlMUbw= +github.com/smallstep/scep v0.0.0-20240214080410-892e41795b99/go.mod h1:4d0ub42ut1mMtvGyMensjuHYEUpRrASvkzLEJvoRQcU= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= +github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.elastic.co/apm/v2 v2.7.0 h1:fbsy3BmTTedIbj7+1Ay9Zpdfuztd8RUk7Dm0JvxRW/M= +go.elastic.co/apm/v2 v2.7.0/go.mod h1:f1Sr3rVJju5winTjsJtKzofdU32L7+Mw/c23cVcn3Io= +go.elastic.co/fastjson v1.1.0 h1:3MrGBWWVIxe/xvsbpghtkFoPciPhOCmjsR/HfwEeQR4= +go.elastic.co/fastjson v1.1.0/go.mod h1:boNGISWMjQsUPy/t6yqt2/1Wx4YNPSe+mZjlyw9vKKI= +go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak= +go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= +go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= +google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/guregu/null.v3 v3.5.0 h1:xTcasT8ETfMcUHn0zTvIYtQud/9Mx5dJqD554SZct0o= +gopkg.in/guregu/null.v3 v3.5.0/go.mod h1:E4tX2Qe3h7QdL+uZ3a0vqvYwKQsRSQKM5V4YltdgH9Y= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/tools/dibble/pkg/command/activities.go b/tools/dibble/pkg/command/activities.go new file mode 100644 index 0000000000..e1ed30c753 --- /dev/null +++ b/tools/dibble/pkg/command/activities.go @@ -0,0 +1,128 @@ +package command + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +// categoryHelp tells reviewers what each subcommand actually plants. Keep +// the keys in sync with seed.ActivityCategories. +var categoryHelp = map[string]string{ + seed.CategorySettings: "global config / feature toggles (agent options, GitOps mode, MDM enable/disable, disk encryption defaults, OS minimum versions, VPP, org logo, enroll secrets, conditional access integrations).", + seed.CategoryProfiles: "configuration profile lifecycle (macOS / Windows / declaration / Android), resent profiles, bootstrap packages, setup assistant, enrollment profile renewal failures.", + seed.CategoryScripts: "script management and execution: ran, added, updated, deleted, edited, canceled, plus batch script scheduled / canceled.", + seed.CategorySoftware: "software and app store apps lifecycle: install, uninstall, add, edit, delete, canceled installs, setup experience software.", + seed.CategoryHosts: "per-host actions: lock / unlock / wipe, enroll / unenroll, disk encryption key access, recovery lock, managed local accounts, cleared passcode, conditional access bypass.", + seed.CategoryUsers: "auth and user management: SSO, login, failed login, create / delete user, global and team role changes.", + seed.CategoryTeams: "team (fleet) lifecycle: created, deleted, applied spec, transferred hosts.", + seed.CategoryPolicies: "policy CRUD plus applied_spec_policy.", + seed.CategoryQueries: "saved + live queries (and legacy packs): created, edited, deleted, applied spec, live query.", + seed.CategoryLabels: "label CRUD: created, edited, deleted.", + seed.CategoryCertificates: "certificate authorities and proxies: NDES, custom SCEP, DigiCert, Hydrant, custom EST, Smallstep, plus add / delete / install / resend certificate.", +} + +// newActivitiesCmd wires `dibble activities` and its per-category +// subcommands. The seeder writes directly to MySQL (activity_past + +// activity_host_past), bypassing the service layer. It is intentionally +// non-idempotent: every invocation inserts a fresh batch tagged with the +// current run id so seeded rows are easy to spot in the UI. +// +// Subcommands: +// +// dibble activities all # one row per activity type (~161) +// dibble activities settings # global config / feature toggle rows +// dibble activities profiles # profile CRUD + renewal failures +// dibble activities scripts # script management & execution +// dibble activities software # software / app store apps lifecycle +// dibble activities hosts # per-host actions (lock, wipe, enroll, ...) +// dibble activities users # auth + user management +// dibble activities teams # team / fleet lifecycle +// dibble activities policies # policy CRUD +// dibble activities queries # saved + live queries (and packs) +// dibble activities labels # label CRUD +// dibble activities certificates # cert proxies and CAs +// +// Invoking `dibble activities` with no subcommand prints the help, mirroring +// the convention from `kubectl get` etc. +func newActivitiesCmd() *cobra.Command { + root := &cobra.Command{ + Use: "activities", + Short: "Seed fake activities directly into MySQL (non-idempotent)", + Long: `Activities aren't seed-able via the Fleet API — they're written by +NewActivity inside the service layer as a side effect of every state-changing +endpoint. dibble shortcuts that by writing rows directly to MySQL, one per +activity type per batch. + +Every name-like value (team names, host names, software titles, profiles, +labels, users, scripts, etc.) is prefixed with "*" so faked rows are obvious +in the activity feed and host activity card. Each run also stamps a unique +tag onto names so re-running dibble produces fresh, distinguishable entries. + +Requires direct access to the Fleet MySQL instance — the default DSN matches +the local docker-compose dev environment.`, + // Show subcommand help when invoked bare. + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + addCommonFlags := func(cmd *cobra.Command) { + cmd.Flags().String("dsn", "fleet:insecure@tcp(localhost:3306)/fleet", "MySQL DSN") + cmd.Flags().Uint("actor-id", 1, "user_id stamped on inserted rows (must exist in users)") + cmd.Flags().String("actor-name", "*Dibble Admin", "user_name stamped on inserted rows") + cmd.Flags().String("actor-email", "*admin@example.com", "user_email stamped on inserted rows") + cmd.Flags().Uint("host-id", 1, "host_id used for host-scoped activities and activity_host_past links") + cmd.Flags().Int("batches", 1, "Number of full passes to insert (each pass writes one row per template with a fresh run tag)") + } + + runCategory := func(category string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + dsn, _ := cmd.Flags().GetString("dsn") + actorID, _ := cmd.Flags().GetUint("actor-id") + actorName, _ := cmd.Flags().GetString("actor-name") + actorEmail, _ := cmd.Flags().GetString("actor-email") + hostID, _ := cmd.Flags().GetUint("host-id") + batches, _ := cmd.Flags().GetInt("batches") + + res := seed.Activities(context.Background(), seederLogger{}, seed.ActivitiesOptions{ + DSN: dsn, + ActorID: actorID, + ActorName: actorName, + ActorEmail: actorEmail, + HostID: hostID, + Batches: batches, + Category: category, + }) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + } + } + + // `all` is the equivalent of the original `dibble activities` command. + allCmd := &cobra.Command{ + Use: "all", + Short: "Seed one row of every activity type (~161 rows per batch)", + RunE: runCategory(seed.CategoryAll), + } + addCommonFlags(allCmd) + root.AddCommand(allCmd) + + for _, category := range seed.ActivityCategories { + category := category // capture + sub := &cobra.Command{ + Use: category, + Short: fmt.Sprintf("Seed %s-related activities", category), + Long: fmt.Sprintf("Seed %s-related activities:\n\n %s", category, categoryHelp[category]), + RunE: runCategory(category), + } + addCommonFlags(sub) + root.AddCommand(sub) + } + + return root +} diff --git a/tools/dibble/pkg/command/all.go b/tools/dibble/pkg/command/all.go new file mode 100644 index 0000000000..c313bc8707 --- /dev/null +++ b/tools/dibble/pkg/command/all.go @@ -0,0 +1,201 @@ +package command + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// runAll executes every seeder in dependency order with the given counts. +// Used by both `dibble all` and the wizard's "everything" preset. +type allCounts struct { + Users int + Teams int + Policies int + Reports int + Labels int + Scripts int + Profiles int + Software int + CAs int + + // EnrollSecrets is per-team and binary (one per team or none). True to + // rotate every team's secret; false to leave them alone. + EnrollSecrets bool + + // ActivityBatches drives the direct-MySQL activity seeder. Zero skips + // it entirely — activities need a DSN, not just an API token, so we + // don't run them by default. + ActivityBatches int + ActivityDSN string + ActivityHostID uint + + // IDPUserCount / IDPHostCount drive the direct-MySQL IDP seeder. Zero + // for either skips it entirely (it needs a DSN like activities). + IDPUserCount int + IDPHostCount int + IDPDSN string + + // CAsDSN is the MySQL DSN used by the CA seeder. CAs > 0 also writes + // directly to MySQL, bypassing the service layer's URL validation. + CAsDSN string +} + +func defaultAllCounts() allCounts { + return allCounts{ + Users: 5, + Teams: 3, + Policies: 5, + Reports: 5, + Labels: 5, + Scripts: 3, + Profiles: 4, + Software: 5, + CAs: 0, + EnrollSecrets: true, + ActivityBatches: 0, + ActivityDSN: "fleet:insecure@tcp(localhost:3306)/fleet", + ActivityHostID: 1, + IDPUserCount: 0, + IDPHostCount: 0, + IDPDSN: "fleet:insecure@tcp(localhost:3306)/fleet", + CAsDSN: "fleet:insecure@tcp(localhost:3306)/fleet", + } +} + +func runAll(c *Client, theme themes.Theme, counts allCounts) error { + start := time.Now() + log := seederLogger{} + + // Collect every seeder's errors so `dibble all` exits non-zero when any + // step failed. Without this, CI / scripted runs treat a half-broken run + // as success. + var allErrs []error + report := func(res seed.Result) { + printf("%s", res.Summary()) + allErrs = append(allErrs, res.Errors...) + } + + // Teams first — many other seeders want a team list. + teams, tRes := seed.Teams(c, log, theme, counts.Teams) + report(tRes) + + report(seed.Users(c, log, theme, counts.Users)) + + if counts.EnrollSecrets { + report(seed.EnrollSecrets(c, log, teams)) + } + + report(seed.Labels(c, log, theme, counts.Labels)) + report(seed.Policies(c, log, theme, teams, counts.Policies)) + report(seed.Reports(c, log, theme, teams, counts.Reports)) + report(seed.Scripts(c, log, theme, teams, counts.Scripts)) + report(seed.Profiles(c, log, theme, teams, counts.Profiles)) + + if counts.Software > 0 { + // `dibble all` lands software under the first existing team if any, + // otherwise no team. Use `dibble software` directly for team + // overrides — this path is intentionally simple. + swOpt := seed.SoftwareOptions{MaintainedAppCount: counts.Software} + if len(teams) > 0 { + swOpt.TeamID = teams[0].ID + } + report(seed.SoftwareCustom(c, log, swOpt)) + report(seed.SoftwareMaintained(c, log, swOpt)) + } + + if counts.CAs > 0 { + report(seed.CAs(context.Background(), log, seed.CAOptions{ + DSN: counts.CAsDSN, + Count: counts.CAs, + })) + } + + if counts.ActivityBatches > 0 { + report(seed.Activities(context.Background(), log, seed.ActivitiesOptions{ + DSN: counts.ActivityDSN, + HostID: counts.ActivityHostID, + Batches: counts.ActivityBatches, + })) + } + + if counts.IDPUserCount > 0 { + report(seed.IDP(context.Background(), c, log, seed.IDPOptions{ + DSN: counts.IDPDSN, + UserCount: counts.IDPUserCount, + HostCount: counts.IDPHostCount, + })) + } + + elapsed := time.Since(start).Round(time.Millisecond) + fmt.Fprintf(stdoutish, "\n🌱 dibble done in %s (theme: %s).\n", elapsed, theme.Name) + return reportErrors(allErrs) +} + +func newAllCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Seed every entity with sensible defaults (idempotent)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + counts := defaultAllCounts() + if v, _ := cmd.Flags().GetInt("users"); v > 0 { + counts.Users = v + } + if v, _ := cmd.Flags().GetInt("teams"); v > 0 { + counts.Teams = v + } + if v, _ := cmd.Flags().GetInt("activity-batches"); v > 0 { + counts.ActivityBatches = v + } + if v, _ := cmd.Flags().GetString("activity-dsn"); v != "" { + counts.ActivityDSN = v + } + if v, _ := cmd.Flags().GetUint("activity-host-id"); v > 0 { + counts.ActivityHostID = v + } + if v, _ := cmd.Flags().GetInt("idp-user-count"); v > 0 { + counts.IDPUserCount = v + } + if v, _ := cmd.Flags().GetInt("idp-host-count"); v >= 0 && cmd.Flags().Changed("idp-host-count") { + counts.IDPHostCount = v + } + if v, _ := cmd.Flags().GetString("idp-dsn"); v != "" { + counts.IDPDSN = v + } + if v, _ := cmd.Flags().GetInt("cas"); v > 0 { + counts.CAs = v + } + if v, _ := cmd.Flags().GetString("cas-dsn"); v != "" { + counts.CAsDSN = v + } + return runAll(c, theme, counts) + }, + } + cmd.Flags().Int("users", 0, "Override user count (0 = default)") + cmd.Flags().Int("teams", 0, "Override team count (0 = default)") + cmd.Flags().Int("activity-batches", 0, "Seed N batches of fake activities via direct MySQL (0 = skip)") + cmd.Flags().String("activity-dsn", "", "MySQL DSN for the activity seeder (default fleet:insecure@tcp(localhost:3306)/fleet)") + cmd.Flags().Uint("activity-host-id", 0, "host_id used by the activity seeder for host-scoped rows (default 1)") + cmd.Flags().Int("idp-user-count", 0, "Seed N users with IDP accounts via direct MySQL (0 = skip)") + cmd.Flags().Int("idp-host-count", 5, "How many hosts to assign IDP accounts to (round-robin); only used when --idp-user-count > 0") + cmd.Flags().String("idp-dsn", "", "MySQL DSN for the IDP seeder (default fleet:insecure@tcp(localhost:3306)/fleet)") + cmd.Flags().Int("cas", 0, "Seed N batches of fake certificate authorities via direct MySQL (0 = skip)") + cmd.Flags().String("cas-dsn", "", "MySQL DSN for the CA seeder (default fleet:insecure@tcp(localhost:3306)/fleet)") + return cmd +} diff --git a/tools/dibble/pkg/command/cas.go b/tools/dibble/pkg/command/cas.go new file mode 100644 index 0000000000..a3a0ca676e --- /dev/null +++ b/tools/dibble/pkg/command/cas.go @@ -0,0 +1,44 @@ +package command + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +// newCAsCmd wires `dibble cas`. The seeder writes directly to MySQL, +// bypassing the service layer so we don't need a real SCEP / DigiCert / +// NDES / EST endpoint for URL validation. Names are prefixed with "*" so +// reviewers can tell at a glance that the CA is dibble-planted and won't +// actually issue certificates (encrypted secret columns are NULL). +func newCAsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cas", + Short: "Seed fake certificate authorities directly into MySQL (non-idempotent)", + Long: `Seed fake certificate authorities directly into MySQL. Each batch writes +one row for each of: custom_scep_proxy, custom_est_proxy, digicert, hydrant, +smallstep. A single NDES row is inserted at the start of the run (Fleet +hardcodes the NDES name and only allows one). + +Encrypted secret columns are left NULL — the CAs list cleanly in the UI but +any request_certificate call against them will fail, which is the point. + +Names are prefixed with "*" plus a per-run tag so seeded rows are obvious +and don't collide across runs.`, + RunE: func(cmd *cobra.Command, args []string) error { + dsn, _ := cmd.Flags().GetString("dsn") + count, _ := cmd.Flags().GetInt("count") + res := seed.CAs(context.Background(), seederLogger{}, seed.CAOptions{ + DSN: dsn, + Count: count, + }) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().String("dsn", "fleet:insecure@tcp(localhost:3306)/fleet", "MySQL DSN") + cmd.Flags().Int("count", 1, "Number of batches; each batch writes one row per non-NDES CA type") + return cmd +} diff --git a/tools/dibble/pkg/command/client.go b/tools/dibble/pkg/command/client.go new file mode 100644 index 0000000000..4163fdea1e --- /dev/null +++ b/tools/dibble/pkg/command/client.go @@ -0,0 +1,176 @@ +package command + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/viper" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +// Client is a thin Fleet API client. Bearer auth, JSON in/out. +type Client struct { + baseURL *url.URL + token string + httpClient *http.Client + dryRun bool + verbose bool +} + +func newClientFromViper() (*Client, error) { + raw := viper.GetString(keyFleetURL) + if raw == "" { + return nil, fmt.Errorf("fleet-url is empty") + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid fleet-url %q: %w", raw, err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("fleet-url must include scheme and host, got %q", raw) + } + httpClient := &http.Client{Timeout: 60 * time.Second} + if viper.GetBool(keyInsecure) { + // Local dev Fleets typically use a self-signed cert. Honor --insecure + // the same way fleetctl does. + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in dev flag + } + } + return &Client{ + baseURL: u, + token: viper.GetString(keyAPIToken), + httpClient: httpClient, + dryRun: viper.GetBool("dry-run"), + verbose: viper.GetBool("verbose"), + }, nil +} + +// AlreadyExistsError signals that the resource conflicted with an existing one. +// Seeders treat this as a soft success (idempotent) unless --strict is added later. +type AlreadyExistsError struct{ Msg string } + +func (e *AlreadyExistsError) Error() string { return e.Msg } +func (e *AlreadyExistsError) IsAlreadyExists() bool { return true } + +func (c *Client) url(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return strings.TrimRight(c.baseURL.String(), "/") + path +} + +// do performs an HTTP request and decodes the JSON response into out (if non-nil). +func (c *Client) do(method, path string, body io.Reader, contentType string, out any) error { + if c.dryRun { + fmt.Printf("[dry-run] %s %s\n", method, path) + return nil + } + req, err := http.NewRequest(method, c.url(path), body) + if err != nil { + return err + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + if c.verbose { + fmt.Fprintf(stderrish, "→ %s %s\n", method, req.URL.Path) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + rb, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("read response body for %s %s: %w", method, req.URL.Path, readErr) + } + if resp.StatusCode == http.StatusConflict || (resp.StatusCode == http.StatusUnprocessableEntity && bytes.Contains(rb, []byte("already exists"))) { + return &AlreadyExistsError{Msg: strings.TrimSpace(string(rb))} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s → %d: %s", method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(rb))) + } + if out != nil && len(rb) > 0 { + if err := json.Unmarshal(rb, out); err != nil { + return fmt.Errorf("decode response: %w (body: %s)", err, string(rb)) + } + } + return nil +} + +func (c *Client) Get(path string, out any) error { + return c.do(http.MethodGet, path, nil, "", out) +} + +func (c *Client) Post(path string, body any, out any) error { + r, err := jsonBody(body) + if err != nil { + return err + } + return c.do(http.MethodPost, path, r, "application/json", out) +} + +func (c *Client) Patch(path string, body any, out any) error { + r, err := jsonBody(body) + if err != nil { + return err + } + return c.do(http.MethodPatch, path, r, "application/json", out) +} + +func (c *Client) Delete(path string) error { + return c.do(http.MethodDelete, path, nil, "", nil) +} + +// PostMultipart uploads form fields + named files. Used for script / profile / installer uploads. +// The Fleet API distinguishes the form *field name* (e.g. "script", "profile") +// from the *filename*, which the server inspects for extension-based platform +// detection — so we carry both via seed.MultipartFile. +func (c *Client) PostMultipart(path string, fields map[string]string, files []seed.MultipartFile, out any) error { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + _ = w.WriteField(k, v) + } + for _, f := range files { + fw, err := w.CreateFormFile(f.FieldName, f.Filename) + if err != nil { + return err + } + if _, err := fw.Write(f.Content); err != nil { + return err + } + } + if err := w.Close(); err != nil { + return err + } + return c.do(http.MethodPost, path, &buf, w.FormDataContentType(), out) +} + +func jsonBody(body any) (io.Reader, error) { + if body == nil { + return nil, nil + } + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(b), nil +} diff --git a/tools/dibble/pkg/command/enroll_secrets.go b/tools/dibble/pkg/command/enroll_secrets.go new file mode 100644 index 0000000000..9f6314bc92 --- /dev/null +++ b/tools/dibble/pkg/command/enroll_secrets.go @@ -0,0 +1,37 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newEnrollSecretsCmd() *cobra.Command { + return &cobra.Command{ + Use: "enroll-secrets", + Aliases: []string{"enrollsecrets"}, + Short: "Seed per-team enroll secrets (the credential fleetd uses to join a team)", + Long: `Seeds an enroll secret for every team. These are the credentials a fleetd +agent presents to register with a team — NOT the "Fleet secrets" (a.k.a. +secret variables) used as template variables in profiles and scripts. + +Find seeded values in the UI under Settings → [team] → Add hosts → Show enroll secret. +The global enroll secret is intentionally left alone.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + teams, err := listExistingTeams(c) + if err != nil { + return err + } + res := seed.EnrollSecrets(c, seederLogger{}, teams) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } +} diff --git a/tools/dibble/pkg/command/hosts.go b/tools/dibble/pkg/command/hosts.go new file mode 100644 index 0000000000..6734256abd --- /dev/null +++ b/tools/dibble/pkg/command/hosts.go @@ -0,0 +1,337 @@ +package command + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// newHostsCmd wires `dibble hosts` — a thin convenience over running +// cmd/osquery-perf. dibble doesn't seed hosts (osquery-perf does that), but +// looking up the right team's enroll secret and stringing the command +// together by hand is enough friction that this subcommand pays for itself. +// +// Flow: +// 1. List teams (and the global / no-team scope) from the Fleet API. +// 2. Radio-button pick — single-select via survey. +// 3. Fetch that team's enroll secret. +// 4. Build the `go run cmd/osquery-perf/agent.go -enroll_secret … -server_url …` +// command, print it, and offer to run it inline. While the child runs, +// Ctrl-C is forwarded to it so the user can shut it down cleanly. +func newHostsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hosts", + Short: "Pick a team and run osquery-perf against it (single-select radio + inline run)", + Long: `Lists existing teams (fleets), lets you pick one with a radio button, fetches +that team's enroll secret, and either prints the matching osquery-perf command +or runs it for you. + +Picking the global / no-team option uses the global enroll secret from +/api/latest/fleet/spec/enroll_secret. + +While osquery-perf runs inline, Ctrl-C is forwarded to it so you can stop the +simulated hosts cleanly. + +dibble doesn't seed hosts itself — this is a convenience around osquery-perf, +which is the canonical host simulator (see cmd/osquery-perf).`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + + teamName, _ := cmd.Flags().GetString("team-name") + teamID, _ := cmd.Flags().GetInt("team-id") + run, _ := cmd.Flags().GetBool("run") + printOnly, _ := cmd.Flags().GetBool("print-only") + hostCount, _ := cmd.Flags().GetInt("host-count") + + picked, err := pickHostsTeam(c, teamID, teamName) + if err != nil { + return err + } + + secret, err := fetchEnrollSecret(c, picked) + if err != nil { + return err + } + + repoRoot, err := findFleetRepoRoot() + if err != nil { + return err + } + + fullCmd, displayCmd := buildOsqueryPerfCmd(repoRoot, secret, viper.GetString(keyFleetURL), hostCount) + scope := picked.scopeDescription() + printf("osquery-perf command for %s:", scope) + fmt.Fprintln(stdoutish, "\n"+displayCmd+"\n") + + switch { + case printOnly: + return nil + case run: + return runOsqueryPerf(fullCmd) + default: + return maybeRunOsqueryPerf(fullCmd) + } + }, + } + cmd.Flags().Int("team-id", -1, "Team to enroll into. -1 (default) = interactive pick. 0 = no team / global.") + cmd.Flags().String("team-name", "", "Team to enroll into, by name. Overrides --team-id.") + cmd.Flags().Int("host-count", 1, "Number of simulated hosts (passed to osquery-perf -host_count)") + cmd.Flags().Bool("run", false, "Skip the confirm prompt and run osquery-perf immediately") + cmd.Flags().Bool("print-only", false, "Skip the confirm prompt and just print the command") + return cmd +} + +// pickedTeam is the outcome of the team picker. ID == 0 means the global / +// no-team scope. +type pickedTeam struct { + ID uint + Name string +} + +func (p pickedTeam) scopeDescription() string { + if p.ID == 0 { + return "no team (global)" + } + return fmt.Sprintf("team %q (id=%d)", p.Name, p.ID) +} + +// pickHostsTeam resolves which team to enroll into. Precedence mirrors +// resolveSoftwareTeamID in software.go: +// +// 1. --team-name (looked up against Fleet) +// 2. --team-id (0 = global, ≥1 = that team) +// 3. Interactive single-select via survey +func pickHostsTeam(c *Client, flagID int, flagName string) (pickedTeam, error) { + teams, err := listExistingTeams(c) + if err != nil { + return pickedTeam{}, fmt.Errorf("list teams: %w", err) + } + + if flagName != "" { + for _, t := range teams { + if strings.EqualFold(t.Name, flagName) { + return pickedTeam{ID: t.ID, Name: t.Name}, nil + } + } + return pickedTeam{}, fmt.Errorf("no team named %q (try --team-id or run `dibble teams` first)", flagName) + } + if flagID == 0 { + return pickedTeam{ID: 0, Name: ""}, nil + } + if flagID > 0 { + for _, t := range teams { + if t.ID == uint(flagID) { + return pickedTeam{ID: t.ID, Name: t.Name}, nil + } + } + // Honor explicit ID even if we couldn't resolve a name. + return pickedTeam{ID: uint(flagID), Name: ""}, nil + } + + options := []string{"(no team — global)"} + for _, t := range teams { + options = append(options, fmt.Sprintf("%s (id=%d)", t.Name, t.ID)) + } + def := options[0] + if len(options) > 1 { + def = options[1] + } + var picked string + if err := survey.AskOne(&survey.Select{ + Message: "Which team should the simulated hosts enroll into?", + Options: options, + Default: def, + }, &picked); err != nil { + return pickedTeam{}, err + } + if picked == options[0] { + return pickedTeam{ID: 0, Name: ""}, nil + } + for _, t := range teams { + if picked == fmt.Sprintf("%s (id=%d)", t.Name, t.ID) { + return pickedTeam{ID: t.ID, Name: t.Name}, nil + } + } + return pickedTeam{}, errors.New("no team selected") +} + +// fetchEnrollSecret returns the first secret for the given team, or the +// first global secret when picked.ID == 0. +func fetchEnrollSecret(c *Client, picked pickedTeam) (string, error) { + if picked.ID == 0 { + var resp struct { + Spec struct { + Secrets []struct { + Secret string `json:"secret"` + } `json:"secrets"` + } `json:"spec"` + } + if err := c.Get("/api/latest/fleet/spec/enroll_secret", &resp); err != nil { + return "", fmt.Errorf("fetch global enroll secret: %w", err) + } + if len(resp.Spec.Secrets) == 0 { + return "", errors.New("global enroll secret is empty — set one in Fleet settings or run `dibble enroll-secrets`") + } + return resp.Spec.Secrets[0].Secret, nil + } + + var resp struct { + Secrets []struct { + Secret string `json:"secret"` + } `json:"secrets"` + } + if err := c.Get(fmt.Sprintf("/api/latest/fleet/fleets/%d/secrets", picked.ID), &resp); err != nil { + return "", fmt.Errorf("fetch enroll secret for team %d: %w", picked.ID, err) + } + if len(resp.Secrets) == 0 { + return "", fmt.Errorf("team %d has no enroll secret — run `dibble enroll-secrets` to add one", picked.ID) + } + return resp.Secrets[0].Secret, nil +} + +// findFleetRepoRoot walks up from the current working directory until it +// finds the parent fleet checkout (the one with cmd/osquery-perf/agent.go). +// Returned path is absolute. +func findFleetRepoRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get cwd: %w", err) + } + dir := cwd + for { + if _, err := os.Stat(filepath.Join(dir, "cmd", "osquery-perf", "agent.go")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find fleet repo root (cmd/osquery-perf/agent.go) walking up from %s", cwd) + } + dir = parent + } +} + +// buildOsqueryPerfCmd assembles the slice of arguments passed to exec.Cmd +// alongside a human-readable rendering for printing back to the user. +func buildOsqueryPerfCmd(repoRoot, secret, serverURL string, hostCount int) (full []string, display string) { + if serverURL == "" { + serverURL = "https://localhost:8080" + } + if hostCount < 1 { + hostCount = 1 + } + agentPath := filepath.Join(repoRoot, "cmd", "osquery-perf", "agent.go") + full = []string{ + "go", "run", agentPath, + "-enroll_secret", secret, + "-server_url", serverURL, + "-host_count", fmt.Sprintf("%d", hostCount), + } + + // Quote the secret for display so copy-paste works in shells. + display = fmt.Sprintf("go run %s -enroll_secret %q -server_url %s -host_count %d", + agentPath, secret, serverURL, hostCount) + return full, display +} + +// maybeRunOsqueryPerf prompts the user, and if confirmed runs osquery-perf +// inline with Ctrl-C forwarded. Non-interactive sessions (no TTY, survey +// returns error) default to printing only. +func maybeRunOsqueryPerf(args []string) error { + wantRun := true + if err := survey.AskOne(&survey.Confirm{ + Message: "Run osquery-perf now? (Ctrl-C to stop)", + Default: true, + }, &wantRun); err != nil { + // Non-interactive — fall back to print-only behavior. + printf("not running osquery-perf (interactive prompt failed: %v)", err) + return nil + } + if !wantRun { + return nil + } + return runOsqueryPerf(args) +} + +// runOsqueryPerf executes the command with stdout / stderr passthrough and +// forwards SIGINT / SIGTERM to the child so Ctrl-C stops the simulated +// hosts cleanly. Returns whatever exit error the child raises, except that +// a signal-initiated shutdown is treated as success. +// +// Used to use exec.CommandContext + signal.Notify with manual Process.Signal +// calls so the child gets the signal directly rather than the cancel-style +// kill exec.CommandContext does by default. +func runOsqueryPerf(args []string) error { + if len(args) < 2 { + return errors.New("runOsqueryPerf: empty command") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + // Disable the default ctx-cancel kill so we control shutdown ourselves. + // (Go 1.20+ exec wires Cancel to SIGKILL; we want SIGINT so osquery-perf + // can run its own deferred cleanup.) + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + return cmd.Process.Signal(syscall.SIGINT) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start osquery-perf: %w", err) + } + + // Forward the first signal we receive; further signals from the user + // keep getting forwarded so a hung child can be killed harder by + // repeated Ctrl-C. + go func() { + for sig := range sigCh { + if cmd.Process == nil { + return + } + _ = cmd.Process.Signal(sig) + } + }() + + err := cmd.Wait() + // A SIGINT-induced exit looks like an error to exec.Wait; treat it as + // success when we asked the child to stop. + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + if status.Signaled() && (status.Signal() == syscall.SIGINT || status.Signal() == syscall.SIGTERM) { + return nil + } + } + } + return err + } + return nil +} + diff --git a/tools/dibble/pkg/command/idp.go b/tools/dibble/pkg/command/idp.go new file mode 100644 index 0000000000..dbfcd14911 --- /dev/null +++ b/tools/dibble/pkg/command/idp.go @@ -0,0 +1,52 @@ +package command + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +// newIDPCmd wires `dibble idp` — populates mdm_idp_accounts for the most +// recently seeded users and assigns those accounts round-robin to the first +// N hosts. Like vulns and activities, this seeder writes directly to MySQL +// (those tables have no public API). +func newIDPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "idp", + Short: "Create IDP accounts for seeded users and assign them to hosts (direct MySQL)", + Long: `Plants rows in mdm_idp_accounts (one per seeded user, matched by email) +and host_mdm_idp_accounts (round-robin assignment to existing hosts). + +Users are fetched via the Fleet API sorted by created_at DESC so dibble-seeded +users surface ahead of the bootstrap admin. Hosts are fetched via the Fleet +API — enroll some first with osquery-perf (try ` + "`dibble hosts`" + `). + +Requires direct MySQL access; the default DSN matches the local docker-compose +dev environment.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + dsn, _ := cmd.Flags().GetString("dsn") + users, _ := cmd.Flags().GetInt("user-count") + hosts, _ := cmd.Flags().GetInt("host-count") + res := seed.IDP(context.Background(), c, seederLogger{}, seed.IDPOptions{ + DSN: dsn, + UserCount: users, + HostCount: hosts, + }) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().String("dsn", "fleet:insecure@tcp(localhost:3306)/fleet", "MySQL DSN") + cmd.Flags().Int("user-count", 3, "How many seeded users to give an IDP account") + cmd.Flags().Int("host-count", 5, "How many hosts to assign IDP accounts to (round-robin)") + return cmd +} diff --git a/tools/dibble/pkg/command/labels.go b/tools/dibble/pkg/command/labels.go new file mode 100644 index 0000000000..034ef49fe5 --- /dev/null +++ b/tools/dibble/pkg/command/labels.go @@ -0,0 +1,33 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newLabelsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "labels", + Short: "Seed dynamic (query-based) labels", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + res := seed.Labels(c, seederLogger{}, theme, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 5, "How many labels to seed") + return cmd +} diff --git a/tools/dibble/pkg/command/log.go b/tools/dibble/pkg/command/log.go new file mode 100644 index 0000000000..87c620e5c8 --- /dev/null +++ b/tools/dibble/pkg/command/log.go @@ -0,0 +1,25 @@ +package command + +import ( + "fmt" + "io" + "os" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// stdoutish / stderrish are package-level writers so tests can swap them. +var ( + stdoutish io.Writer = os.Stdout + stderrish io.Writer = os.Stderr +) + +// printf writes a line tagged with the tapir snout glyph. +func printf(format string, a ...any) { + fmt.Fprintf(stdoutish, themes.TapirSnout+" "+format+"\n", a...) +} + +// warnf writes a line to stderr without the glyph. +func warnf(format string, a ...any) { + fmt.Fprintf(stderrish, "dibble: "+format+"\n", a...) +} diff --git a/tools/dibble/pkg/command/ping.go b/tools/dibble/pkg/command/ping.go new file mode 100644 index 0000000000..c864a1d2e7 --- /dev/null +++ b/tools/dibble/pkg/command/ping.go @@ -0,0 +1,33 @@ +package command + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newPingCmd() *cobra.Command { + return &cobra.Command{ + Use: "ping", + Short: "Verify dibble can reach the Fleet API", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + var resp struct { + Version string `json:"version"` + Branch string `json:"branch"` + Build string `json:"build"` + } + if err := c.Get("/api/latest/fleet/version", &resp); err != nil { + return err + } + fmt.Printf("Fleet %s (branch %s, build %s) — connection OK 🌱\n", resp.Version, resp.Branch, resp.Build) + return nil + }, + } +} diff --git a/tools/dibble/pkg/command/policies.go b/tools/dibble/pkg/command/policies.go new file mode 100644 index 0000000000..7fb41237df --- /dev/null +++ b/tools/dibble/pkg/command/policies.go @@ -0,0 +1,63 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newPoliciesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policies", + Short: "Seed global policies (and a couple per existing team)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + teams, err := listExistingTeams(c) + if err != nil { + return err + } + res := seed.Policies(c, seederLogger{}, theme, teams, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 5, "How many global policies to seed (plus 2 per team)") + return cmd +} + +// listExistingTeams asks Fleet for the team list so subcommands invoked on +// their own (without prior `dibble teams`) can still scope per-team work. +func listExistingTeams(c *Client) ([]seed.Team, error) { + var resp struct { + Teams []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"teams"` + Fleets []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"fleets"` + } + if err := c.Get("/api/latest/fleet/fleets?per_page=500", &resp); err != nil { + return nil, err + } + out := make([]seed.Team, 0, len(resp.Teams)+len(resp.Fleets)) + for _, t := range resp.Teams { + out = append(out, seed.Team{ID: t.ID, Name: t.Name}) + } + for _, t := range resp.Fleets { + out = append(out, seed.Team{ID: t.ID, Name: t.Name}) + } + return out, nil +} diff --git a/tools/dibble/pkg/command/profiles.go b/tools/dibble/pkg/command/profiles.go new file mode 100644 index 0000000000..900bbefc4f --- /dev/null +++ b/tools/dibble/pkg/command/profiles.go @@ -0,0 +1,37 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newProfilesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "profiles", + Short: "Seed MDM configuration profiles (Apple .mobileconfig + Windows .xml)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + teams, err := listExistingTeams(c) + if err != nil { + return err + } + res := seed.Profiles(c, seederLogger{}, theme, teams, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 4, "How many global profiles to seed (plus 2 per team)") + return cmd +} diff --git a/tools/dibble/pkg/command/reports.go b/tools/dibble/pkg/command/reports.go new file mode 100644 index 0000000000..23c8b0fe17 --- /dev/null +++ b/tools/dibble/pkg/command/reports.go @@ -0,0 +1,38 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newReportsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "reports", + Aliases: []string{"queries"}, + Short: "Seed saved reports (formerly 'queries')", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + teams, err := listExistingTeams(c) + if err != nil { + return err + } + res := seed.Reports(c, seederLogger{}, theme, teams, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 5, "How many global reports to seed (plus 1 per team)") + return cmd +} diff --git a/tools/dibble/pkg/command/root.go b/tools/dibble/pkg/command/root.go new file mode 100644 index 0000000000..10ad2bce0e --- /dev/null +++ b/tools/dibble/pkg/command/root.go @@ -0,0 +1,229 @@ +package command + +import ( + cryptorand "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Build-time variables (set via -ldflags). +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +// Execute is the entry point used by cmd/dibble/main.go. It constructs the +// root cobra command, routes no-arg invocations through the wizard, and runs +// whichever path was chosen. Returns a non-nil error on failure. +func Execute(args []string) error { + rootCmd := newRootCmd() + if shouldRunWizard(args) { + // Parse persistent flags so values like + // dibble --fleet-url X --api-token Y + // are honored by the wizard (and bound into viper via + // BindPFlag in newRootCmd). Without this, the wizard would + // prompt for values the user already provided. + if err := rootCmd.ParseFlags(args); err != nil { + return err + } + return runWizard(rootCmd) + } + return rootCmd.Execute() +} + +// shouldRunWizard reports whether `dibble` was invoked with no subcommand, +// in which case we drop into the interactive wizard. Flags-only invocations +// (e.g. `dibble --fleet-url X`) still trigger the wizard so it can fill in +// any missing config. +func shouldRunWizard(args []string) bool { + for _, a := range args { + switch a { + case "help", "--help", "-h", "completion", "--version": + return false + } + // First non-flag positional → a subcommand was given. + if len(a) > 0 && a[0] != '-' { + return false + } + if a == "--no-wizard" { + return false + } + } + return true +} + +// Config keys (viper). +const ( + keyFleetURL = "fleet_url" + keyAPIToken = "api_token" + keyTheme = "theme" + keyInsecure = "insecure" + keySuffix = "suffix" +) + +// newRootCmd builds the root cobra command and wires viper. +// +// Configuration precedence (highest first): +// 1. Command-line flag +// 2. Environment variable (FLEET_URL, FLEET_API_TOKEN, DIBBLE_THEME) +// 3. ~/.dibble.yaml +// 4. Built-in defaults +func newRootCmd() *cobra.Command { + cobra.OnInitialize(initConfig) + + root := &cobra.Command{ + Use: "dibble", + Short: "Fleet's seed slinger — plant test data into a Fleet server", + Long: `dibble is the one-stop CLI for seeding test data into a Fleet server. +Run it with no arguments for an interactive wizard, or pass a subcommand to +script it. + +Hosts are intentionally out of scope — use cmd/osquery-perf for that.`, + SilenceUsage: true, + // Errors are surfaced via reportErrors (warnf per error) or main.go + // (one "dibble: " line for everything else). Cobra's own + // "Error: " print would duplicate them. + SilenceErrors: true, + } + + // Persistent flags (apply to every subcommand). + root.PersistentFlags().StringP("fleet-url", "u", "", "Fleet server URL (env FLEET_URL)") + root.PersistentFlags().StringP("api-token", "a", "", "Fleet API token (env FLEET_API_TOKEN)") + root.PersistentFlags().StringP("config", "c", "", "Config file (default $HOME/.dibble.yaml)") + root.PersistentFlags().StringP("theme", "t", "mix", "Easter-egg theme: mix, hitchhikers, goodplace, parksrec, tng, lotr, dbz, robin_williams, ghibli, cosmere, sailor_moon") + root.PersistentFlags().BoolP("dry-run", "d", false, "Print actions without calling the Fleet API") + root.PersistentFlags().BoolP("verbose", "v", false, "Verbose logging") + root.PersistentFlags().Bool("no-wizard", false, "Disable the interactive wizard (always error on missing config)") + root.PersistentFlags().BoolP("insecure", "k", false, "Skip TLS certificate verification (for self-signed dev certs)") + root.PersistentFlags().StringP("suffix", "s", "", `Append to every generated name to avoid collisions on re-run. Use "auto" for a random 4-char suffix per run.`) + + // Bind to viper so env / config-file values flow through. The + // config/dry-run/verbose flags are read via viper.Get* in initConfig + // and the HTTP client; without explicit binding they'd silently + // default to zero values regardless of what the user passed. + _ = viper.BindPFlag(keyFleetURL, root.PersistentFlags().Lookup("fleet-url")) + _ = viper.BindPFlag(keyAPIToken, root.PersistentFlags().Lookup("api-token")) + _ = viper.BindPFlag(keyTheme, root.PersistentFlags().Lookup("theme")) + _ = viper.BindPFlag(keyInsecure, root.PersistentFlags().Lookup("insecure")) + _ = viper.BindPFlag(keySuffix, root.PersistentFlags().Lookup("suffix")) + _ = viper.BindPFlag("config", root.PersistentFlags().Lookup("config")) + _ = viper.BindPFlag("dry-run", root.PersistentFlags().Lookup("dry-run")) + _ = viper.BindPFlag("verbose", root.PersistentFlags().Lookup("verbose")) + + // Env mapping. FLEET_URL / FLEET_API_TOKEN feel natural to people coming + // from fleetctl, even though the dibble prefix would be more consistent. + viper.SetEnvPrefix("DIBBLE") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + _ = viper.BindEnv(keyFleetURL, "FLEET_URL", "DIBBLE_FLEET_URL") + _ = viper.BindEnv(keyAPIToken, "FLEET_API_TOKEN", "DIBBLE_API_TOKEN") + + // Subcommands. + root.AddCommand(newVersionCmd()) + root.AddCommand(newPingCmd()) + root.AddCommand(newAllCmd()) + root.AddCommand(newUsersCmd()) + root.AddCommand(newTeamsCmd()) + root.AddCommand(newPoliciesCmd()) + root.AddCommand(newReportsCmd()) + root.AddCommand(newLabelsCmd()) + root.AddCommand(newScriptsCmd()) + root.AddCommand(newProfilesCmd()) + root.AddCommand(newSoftwareCmd()) + root.AddCommand(newEnrollSecretsCmd()) + root.AddCommand(newCAsCmd()) + root.AddCommand(newVulnsCmd()) + root.AddCommand(newActivitiesCmd()) + root.AddCommand(newHostsCmd()) + root.AddCommand(newIDPCmd()) + + return root +} + +func initConfig() { + if cfgFile := viper.GetString("config"); cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + return + } + viper.AddConfigPath(home) + viper.SetConfigName(".dibble") + viper.SetConfigType("yaml") + } + _ = viper.ReadInConfig() // missing config file is fine — wizard will help. +} + +// configPath returns the path used to persist wizard answers. +func configPath() string { + if p := viper.ConfigFileUsed(); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return ".dibble.yaml" + } + return filepath.Join(home, ".dibble.yaml") +} + +func requireConfig() error { + if viper.GetString(keyFleetURL) == "" { + return fmt.Errorf("missing --fleet-url (or FLEET_URL, or ~/.dibble.yaml). Run `dibble` with no args for the wizard") + } + if viper.GetString(keyAPIToken) == "" { + return fmt.Errorf("missing --api-token (or FLEET_API_TOKEN, or ~/.dibble.yaml). Run `dibble` with no args for the wizard") + } + return nil +} + +// currentTheme returns the theme selected by viper with the suffix applied. +// "auto" expands to a 4-character random hex suffix that's stable for the +// life of this process so every entity in the run shares the same tag. +func currentTheme() (themes.Theme, error) { + t, err := themes.Get(viper.GetString(keyTheme)) + if err != nil { + return themes.Theme{}, err + } + suffix := viper.GetString(keySuffix) + if suffix == "auto" { + suffix = autoSuffix() + } + t.Suffix = suffix + return t, nil +} + +var cachedAutoSuffix string + +func autoSuffix() string { + if cachedAutoSuffix != "" { + return cachedAutoSuffix + } + var b [2]byte + if _, err := cryptorand.Read(b[:]); err != nil { + cachedAutoSuffix = "x" + return cachedAutoSuffix + } + cachedAutoSuffix = hex.EncodeToString(b[:]) + return cachedAutoSuffix +} + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print dibble version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("dibble %s (commit %s, built %s)\n", version, commit, date) + fmt.Println("— Dibble the Tapir 🌱") + }, + } +} diff --git a/tools/dibble/pkg/command/scripts.go b/tools/dibble/pkg/command/scripts.go new file mode 100644 index 0000000000..c8d77cab78 --- /dev/null +++ b/tools/dibble/pkg/command/scripts.go @@ -0,0 +1,37 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newScriptsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "scripts", + Short: "Seed saved scripts (.sh, .ps1, .zsh)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + teams, err := listExistingTeams(c) + if err != nil { + return err + } + res := seed.Scripts(c, seederLogger{}, theme, teams, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 3, "How many global scripts to seed (plus 1 per team)") + return cmd +} diff --git a/tools/dibble/pkg/command/seederlog.go b/tools/dibble/pkg/command/seederlog.go new file mode 100644 index 0000000000..3c4ab8defd --- /dev/null +++ b/tools/dibble/pkg/command/seederlog.go @@ -0,0 +1,9 @@ +package command + +// seederLogger adapts the package-level printf helper to the seed.Logger +// interface, so seed/ doesn't need to import this package. +type seederLogger struct{} + +func (seederLogger) Printf(format string, a ...any) { + printf(format, a...) +} diff --git a/tools/dibble/pkg/command/software.go b/tools/dibble/pkg/command/software.go new file mode 100644 index 0000000000..c4d2385f82 --- /dev/null +++ b/tools/dibble/pkg/command/software.go @@ -0,0 +1,144 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +// newSoftwareCmd wires `dibble software` and its subcommands. The seeder +// uploads the curated installer fixtures and registers a few Fleet-maintained +// apps. Both paths target one team — the first existing team by default, or +// whichever team --team-id / --team-name selects. +func newSoftwareCmd() *cobra.Command { + root := &cobra.Command{ + Use: "software", + Short: "Seed software (custom installers + Fleet-maintained apps)", + Long: `Seed real software into Fleet for testing. Pick a subcommand: + + dibble software all — upload custom installers AND add maintained apps + dibble software custom — upload curated installers (2-3 per extension) + dibble software maintained — add Fleet-maintained apps from the server's catalog + +Custom uploads cover .pkg / .deb / .msi / .exe / .rpm / .tar.gz / .ipa fixtures bundled +into the dibble binary. Maintained-app entries are read from +/api/latest/fleet/software/fleet_maintained_apps and POSTed in catalog order. + +All work scopes to a single team. By default dibble picks the first team Fleet +returns; override with --team-id or --team-name. Use --team-id 0 for the +no-team / global scope.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, + } + + addCommonFlags := func(cmd *cobra.Command) { + cmd.Flags().Int("team-id", -1, "Team to upload software to. -1 (default) = first team Fleet returns. 0 = no team / global.") + cmd.Flags().String("team-name", "", "Team to upload software to, by name. Overrides --team-id if set.") + cmd.Flags().Int("maintained-count", 3, "How many Fleet-maintained apps to add (used by `all` and `maintained`)") + } + + mkSub := func(use, short string, runFn func(client *Client, opt seed.SoftwareOptions) error) *cobra.Command { + sub := &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + teamID, err := resolveSoftwareTeamID(cmd, c) + if err != nil { + return err + } + maintained, _ := cmd.Flags().GetInt("maintained-count") + return runFn(c, seed.SoftwareOptions{ + TeamID: teamID, + MaintainedAppCount: maintained, + }) + }, + } + addCommonFlags(sub) + return sub + } + + root.AddCommand(mkSub("custom", "Upload curated installer fixtures (2-3 per extension)", + func(c *Client, opt seed.SoftwareOptions) error { + res := seed.SoftwareCustom(c, seederLogger{}, opt) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + })) + + root.AddCommand(mkSub("maintained", "Add Fleet-maintained apps from the server's catalog", + func(c *Client, opt seed.SoftwareOptions) error { + if opt.MaintainedAppCount <= 0 { + opt.MaintainedAppCount = 3 + } + res := seed.SoftwareMaintained(c, seederLogger{}, opt) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + })) + + root.AddCommand(mkSub("all", "Upload custom installers AND add maintained apps", + func(c *Client, opt seed.SoftwareOptions) error { + if opt.MaintainedAppCount <= 0 { + opt.MaintainedAppCount = 3 + } + cRes := seed.SoftwareCustom(c, seederLogger{}, opt) + printf("%s", cRes.Summary()) + mRes := seed.SoftwareMaintained(c, seederLogger{}, opt) + printf("%s", mRes.Summary()) + combined := append([]error{}, cRes.Errors...) + combined = append(combined, mRes.Errors...) + return reportErrors(combined) + })) + + return root +} + +// resolveSoftwareTeamID determines which team uploads land under. Precedence: +// +// 1. --team-name (looked up against Fleet) +// 2. --team-id (0 means global, -1 means "auto-pick") +// 3. Auto-pick: first team Fleet returns. If none exist, prompt +// interactively when a TTY is attached; otherwise default to 0 / global. +func resolveSoftwareTeamID(cmd *cobra.Command, c *Client) (uint, error) { + if name, _ := cmd.Flags().GetString("team-name"); name != "" { + teams, err := listExistingTeams(c) + if err != nil { + return 0, fmt.Errorf("list teams to resolve --team-name=%q: %w", name, err) + } + for _, t := range teams { + if strings.EqualFold(t.Name, name) { + return t.ID, nil + } + } + return 0, fmt.Errorf("no team named %q (try --team-id or run `dibble teams` first)", name) + } + + explicit, _ := cmd.Flags().GetInt("team-id") + if explicit == 0 { + return 0, nil + } + if explicit > 0 { + return uint(explicit), nil + } + + // Auto-pick: first team Fleet returns. The wizard's software step (or + // --team-id / --team-name) is the path for picking a specific team. + teams, err := listExistingTeams(c) + if err != nil { + return 0, fmt.Errorf("list teams for auto-pick: %w", err) + } + if len(teams) == 0 { + printf("no teams found — falling back to no-team / global scope") + return 0, nil + } + printf("software: auto-picked team %q (id=%d) — override with --team-id or --team-name", teams[0].Name, teams[0].ID) + return teams[0].ID, nil +} diff --git a/tools/dibble/pkg/command/teams.go b/tools/dibble/pkg/command/teams.go new file mode 100644 index 0000000000..0b9a6651ed --- /dev/null +++ b/tools/dibble/pkg/command/teams.go @@ -0,0 +1,34 @@ +package command + +import ( + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newTeamsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "teams", + Aliases: []string{"fleets"}, + Short: "Seed teams (aka fleets)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + _, res := seed.Teams(c, seederLogger{}, theme, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 3, "How many teams to seed") + return cmd +} diff --git a/tools/dibble/pkg/command/users.go b/tools/dibble/pkg/command/users.go new file mode 100644 index 0000000000..d16c964817 --- /dev/null +++ b/tools/dibble/pkg/command/users.go @@ -0,0 +1,50 @@ +package command + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newUsersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Short: "Seed Fleet users with themed names and rotating roles", + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireConfig(); err != nil { + return err + } + c, err := newClientFromViper() + if err != nil { + return err + } + theme, err := currentTheme() + if err != nil { + return err + } + count, _ := cmd.Flags().GetInt("count") + res := seed.Users(c, seederLogger{}, theme, count) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().Int("count", 5, "How many users to seed") + return cmd +} + +// ErrSeederFailed is returned by reportErrors when one or more seeders +// produced errors. The errors have already been written to stderr by +// reportErrors, so main.go skips re-printing them and just exits non-zero. +var ErrSeederFailed = errors.New("dibble: one or more seeders had errors") + +func reportErrors(errs []error) error { + if len(errs) == 0 { + return nil + } + for _, e := range errs { + warnf("%v", e) + } + return ErrSeederFailed +} diff --git a/tools/dibble/pkg/command/vulns.go b/tools/dibble/pkg/command/vulns.go new file mode 100644 index 0000000000..481fb6d19a --- /dev/null +++ b/tools/dibble/pkg/command/vulns.go @@ -0,0 +1,49 @@ +package command + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/seed" +) + +func newVulnsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "vulns", + Short: "Seed software rows directly into MySQL for the vuln scanner to chew on", + Long: `Writes rows directly into the software table so Fleet's background +vulnerability scanner has inventory to process. Each row gets a +fleet-compatible checksum, so re-runs are idempotent against the unique +software-checksum index. + +NOTE: this only writes the software table. It does NOT create hosts, +host_software entries, or software_cpe associations — vulnerabilities won't +surface against any host until those rows exist (via real ingest or a +follow-up seeder). Use this when you need plausible inventory volume; use +osquery-perf or the legacy seed_vuln_data tool when you need end-to-end +vulnerable-host scenarios. + +Requires direct access to the Fleet MySQL instance. The default DSN matches +the local docker-compose dev environment.`, + RunE: func(cmd *cobra.Command, args []string) error { + dsn, _ := cmd.Flags().GetString("dsn") + macos, _ := cmd.Flags().GetInt("macos") + ubuntu, _ := cmd.Flags().GetInt("ubuntu") + windows, _ := cmd.Flags().GetInt("windows") + res := seed.Vulns(context.Background(), seederLogger{}, seed.VulnsOptions{ + DSN: dsn, + MacOS: macos, + Ubuntu: ubuntu, + Windows: windows, + }) + printf("%s", res.Summary()) + return reportErrors(res.Errors) + }, + } + cmd.Flags().String("dsn", "fleet:insecure@tcp(localhost:3306)/fleet", "MySQL DSN") + cmd.Flags().Int("macos", 0, "Number of macOS software rows to insert") + cmd.Flags().Int("ubuntu", 0, "Number of Ubuntu software rows to insert") + cmd.Flags().Int("windows", 0, "Number of Windows software rows to insert") + return cmd +} diff --git a/tools/dibble/pkg/command/wizard.go b/tools/dibble/pkg/command/wizard.go new file mode 100644 index 0000000000..8ee07f177a --- /dev/null +++ b/tools/dibble/pkg/command/wizard.go @@ -0,0 +1,365 @@ +package command + +import ( + "crypto/tls" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// runWizard drives the interactive no-arg flow. The cobra root command is +// passed in so the wizard can invoke `runAll` against the same client setup +// the subcommands use. +func runWizard(root *cobra.Command) error { + // Cobra hasn't run yet (we bypassed Execute), so initialize viper manually. + initConfig() + + fmt.Fprint(stdoutish, themes.TapirSmall) + fmt.Fprintln(stdoutish, "Welcome to dibble. Let's plant some seeds.") + + // Step 1: resolve config (URL + token), prompting only for what's missing. + urlAsked, tokenAsked, err := promptConfig() + if err != nil { + return err + } + // We defer the "save config?" prompt until after theme and the + // optional --insecure toggle so the persisted file reflects ALL of + // the user's final choices, not just URL+token. + shouldOfferSave := urlAsked || tokenAsked + + // Step 2: ping. Fail fast so the user knows their config is wrong before + // they spend time picking themes. If a TLS verification error trips us + // up against a self-signed dev cert, offer to flip --insecure. + client, err := newClientFromViper() + if err != nil { + return err + } + var ver struct { + Version string `json:"version"` + } + pingErr := client.Get("/api/latest/fleet/version", &ver) + if pingErr != nil && isTLSVerificationError(pingErr) && !viper.GetBool(keyInsecure) { + retry := true + if err := survey.AskOne( + &survey.Confirm{Message: "TLS cert isn't trusted. Skip verification (insecure)?", Default: true}, + &retry, + ); err != nil { + return err + } + if retry { + viper.Set(keyInsecure, true) + client, err = newClientFromViper() + if err != nil { + return err + } + pingErr = client.Get("/api/latest/fleet/version", &ver) + } + } + if pingErr != nil { + return fmt.Errorf("could not reach Fleet at %s: %w", viper.GetString(keyFleetURL), pingErr) + } + printf("connected to Fleet %s ✓", ver.Version) + + // Step 3: theme picker. + chosenTheme := viper.GetString(keyTheme) + if err := survey.AskOne( + &survey.Select{ + Message: "Theme?", + Options: themes.Names(), + Default: chosenTheme, + }, + &chosenTheme, + ); err != nil { + return err + } + viper.Set(keyTheme, chosenTheme) + theme, err := currentTheme() + if err != nil { + return err + } + + // Step 4: persist final config (URL, token, theme, insecure) if we + // collected anything new. Doing this after the TLS toggle and theme + // pick means the saved file actually matches the run the user is + // about to do. + if shouldOfferSave { + save := true + if err := survey.AskOne( + &survey.Confirm{Message: fmt.Sprintf("Save these to %s?", configPath()), Default: true}, + &save, + ); err != nil { + return err + } + if save { + if err := writeConfigFile(); err != nil { + warnf("could not save config: %v", err) + } else { + printf("config saved to %s", configPath()) + } + } + } + + // Step 5: entity multi-select. + type entity struct { + Label string + Key string + Slow bool + Default bool + } + // Note: `vulns` is intentionally NOT offered here. It needs direct + // MySQL access (a DSN) that the wizard doesn't collect — run + // `dibble vulns --dsn ... --macos N --ubuntu N --windows N` directly. + entities := []entity{ + {"users", "users", false, true}, + {"teams", "teams", false, true}, + {"enroll-secrets", "enroll-secrets", false, true}, + {"labels", "labels", false, true}, + {"policies", "policies", false, true}, + {"reports (queries)", "reports", false, false}, + {"scripts", "scripts", false, false}, + {"profiles (Apple + Windows MDM)", "profiles", false, false}, + {"software (titles, no upload)", "software", false, false}, + {"activities (direct MySQL, non-idempotent)", "activities", true, false}, + {"idp (direct MySQL, links users + hosts)", "idp", false, false}, + {"cas (direct MySQL, non-idempotent)", "cas", false, false}, + } + labels := make([]string, len(entities)) + defaults := []string{} + for i, e := range entities { + labels[i] = e.Label + if e.Slow { + labels[i] += " ⚠" + } + if e.Default { + defaults = append(defaults, labels[i]) + } + } + var picked []string + if err := survey.AskOne( + &survey.MultiSelect{Message: "What would you like to seed?", Options: labels, Default: defaults}, + &picked, + ); err != nil { + return err + } + wanted := map[string]bool{} + for _, p := range picked { + for i, e := range entities { + if p == labels[i] { + wanted[e.Key] = true + } + } + } + + // Step 6: counts. + counts := defaultAllCounts() + // Pre-fill IDP defaults so the count prompt (and runWizardSelection + // downstream) sees sensible numbers when the user picked IDP. Default + // is 0 in defaultAllCounts because `dibble all` skips IDP unless + // explicitly requested. + if wanted["idp"] { + counts.IDPUserCount = 3 + counts.IDPHostCount = 5 + } + customise := false + if err := survey.AskOne( + &survey.Confirm{Message: "Use default counts?", Default: true}, + &customise, + ); err != nil { + return err + } + customise = !customise // confirm says "use defaults yes" → don't customise + + if customise { + counts = promptCounts(wanted, counts) + } + + // Step 7: run. + return runWizardSelection(client, theme, wanted, counts) +} + +// promptConfig prompts for any missing URL/token and updates viper. Returns +// whether each value was actually asked for (so we know whether to offer +// persisting). +func promptConfig() (bool, bool, error) { + urlAsked, tokenAsked := false, false + + if viper.GetString(keyFleetURL) == "" { + var u string + err := survey.AskOne( + &survey.Input{Message: "Fleet URL?", Default: "http://localhost:8080"}, + &u, + survey.WithValidator(func(in any) error { + s, _ := in.(string) + if s == "" { + return errors.New("required") + } + if _, err := url.Parse(s); err != nil { + return err + } + return nil + }), + ) + if err != nil { + return false, false, err + } + viper.Set(keyFleetURL, u) + urlAsked = true + } + + if viper.GetString(keyAPIToken) == "" { + var t string + err := survey.AskOne( + &survey.Password{Message: "Fleet API token?"}, + &t, + survey.WithValidator(func(in any) error { + s, _ := in.(string) + if strings.TrimSpace(s) == "" { + return errors.New("required") + } + return nil + }), + ) + if err != nil { + return false, false, err + } + viper.Set(keyAPIToken, t) + tokenAsked = true + } + return urlAsked, tokenAsked, nil +} + +// writeConfigFile persists only the keys dibble manages. Existing fields in +// the file are preserved on best-effort: we read first, then merge. +func writeConfigFile() error { + path := configPath() + existing := map[string]any{} + if data, err := os.ReadFile(path); err == nil { + _ = yaml.Unmarshal(data, &existing) + } + existing[keyFleetURL] = viper.GetString(keyFleetURL) + existing[keyAPIToken] = viper.GetString(keyAPIToken) + existing[keyTheme] = viper.GetString(keyTheme) + // Always write the current insecure value (not just when true) so that + // toggling it off in a later run actually clears the persisted setting. + existing[keyInsecure] = viper.GetBool(keyInsecure) + data, err := yaml.Marshal(existing) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func promptCounts(wanted map[string]bool, defaults allCounts) allCounts { + out := defaults + ask := func(label string, dst *int) { + var s string + _ = survey.AskOne( + &survey.Input{Message: label, Default: fmt.Sprintf("%d", *dst)}, + &s, + ) + var v int + _, _ = fmt.Sscanf(s, "%d", &v) + if v > 0 { + *dst = v + } + } + if wanted["users"] { + ask("users count", &out.Users) + } + if wanted["teams"] { + ask("teams count", &out.Teams) + } + if wanted["policies"] { + ask("policies count", &out.Policies) + } + if wanted["reports"] { + ask("reports count", &out.Reports) + } + if wanted["labels"] { + ask("labels count", &out.Labels) + } + if wanted["scripts"] { + ask("scripts count", &out.Scripts) + } + if wanted["profiles"] { + ask("profiles count", &out.Profiles) + } + if wanted["software"] { + ask("software count", &out.Software) + } + if wanted["cas"] { + ask("CAs count", &out.CAs) + } + if wanted["idp"] { + ask("idp user count", &out.IDPUserCount) + ask("idp host count (round-robin assignments)", &out.IDPHostCount) + } + return out +} + +// isTLSVerificationError reports whether the error came from an untrusted +// TLS certificate — the common pain point with self-signed dev Fleets. +func isTLSVerificationError(err error) bool { + if err == nil { + return false + } + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + return true + } + msg := err.Error() + return strings.Contains(msg, "x509:") || strings.Contains(msg, "certificate signed by unknown authority") +} + +func runWizardSelection(c *Client, theme themes.Theme, wanted map[string]bool, counts allCounts) error { + // Zero out entities the user didn't pick — runAll uses count > 0 as + // the implicit "do this" signal for everything except teams, which we + // gate explicitly to keep downstream seeders sensible. + if !wanted["users"] { + counts.Users = 0 + } + if !wanted["teams"] { + counts.Teams = 0 + } + if !wanted["policies"] { + counts.Policies = 0 + } + if !wanted["reports"] { + counts.Reports = 0 + } + if !wanted["labels"] { + counts.Labels = 0 + } + if !wanted["scripts"] { + counts.Scripts = 0 + } + if !wanted["profiles"] { + counts.Profiles = 0 + } + if !wanted["software"] { + counts.Software = 0 + } + if !wanted["cas"] { + counts.CAs = 0 + } + if !wanted["activities"] { + counts.ActivityBatches = 0 + } else if counts.ActivityBatches == 0 { + counts.ActivityBatches = 1 + } + if !wanted["idp"] { + counts.IDPUserCount = 0 + counts.IDPHostCount = 0 + } + counts.EnrollSecrets = wanted["enroll-secrets"] + return runAll(c, theme, counts) +} diff --git a/tools/dibble/pkg/seed/activities.go b/tools/dibble/pkg/seed/activities.go new file mode 100644 index 0000000000..f2ce9ee32b --- /dev/null +++ b/tools/dibble/pkg/seed/activities.go @@ -0,0 +1,606 @@ +package seed + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +// ActivitiesOptions configures the activity seeder. DSN points at a Fleet +// MySQL instance; the seeder writes directly to activity_past (and +// activity_host_past for host-scoped rows) without going through the service +// layer, so no webhooks fire. +// +// The seeder is intentionally non-idempotent: every call creates new rows. +// All user-facing name-like fields are prefixed with "*" plus a per-run tag +// so faked rows are obvious in the UI and don't pile up under one name. +type ActivitiesOptions struct { + DSN string + ActorID uint + ActorName string + ActorEmail string + HostID uint + Batches int + + // Category selects which subset of activity templates to seed. Empty or + // "all" runs every template. See ActivityCategories for the supported + // values. + Category string +} + +// fakePrefix marks any string value the seeder writes so reviewers can tell +// at a glance the row was generated by dibble and not produced by a real +// flow. We hardcode the prefix in stringExample below; tests can compare +// against this constant. +const fakePrefix = "*" + +// hostIDer matches fleet activities that report associated hosts. The +// interface mirrors what NewActivity does internally; defining it here +// avoids pulling in the fleet-internal activity bounded context. +type hostIDer interface { + HostIDs() []uint +} + +// stringExample returns a deterministic example value for the given field, +// based on the json tag (preferred) or Go field name. Returned strings are +// prefixed with fakePrefix and tagged with runTag so the rendered UI copy +// makes it obvious the row is faked. Passing runTag explicitly (instead of +// reading a package-level global) keeps Activities reentrant. +// +// Lifted from tools/seed-activities/main.go (PR #45713) and adapted to +// always include the fake marker. +func stringExample(jsonTag, fieldName, runTag string) string { + name := strings.ToLower(jsonTag) + if name == "" { + name = strings.ToLower(fieldName) + } + + // Some fields are not user-facing strings — they're enums or IDs the + // activity templates branch on. We return plain values for those so the + // "*" doesn't break the rendering. + switch { + case strings.Contains(name, "policy_critical"): + return "false" + case strings.Contains(name, "platform"): + return "darwin" + case strings.Contains(name, "status"): + return "installed" + case strings.Contains(name, "role"): + return "admin" + case strings.Contains(name, "mode"): + return "all" + case strings.Contains(name, "script_execution_id"), + strings.Contains(name, "command_uuid"), + strings.Contains(name, "install_uuid"), + strings.Contains(name, "uuid"): + return "00000000-0000-0000-0000-000000000001" + case strings.Contains(name, "url"): + return "https://example.com/" + } + + tag := fakePrefix + if runTag != "" { + tag = fakePrefix + runTag + " " + } + + switch { + case strings.Contains(name, "host_display") || name == "hostname": + return tag + "example-host" + case name == "software_display_name" || strings.HasPrefix(name, "software_title"): + return tag + "GitHub Desktop" + case strings.Contains(name, "software_package"): + return tag + "GitHubDesktop-arm64.dmg" + case strings.Contains(name, "software_icon_url"): + return "https://example.com/icon.png" + case strings.Contains(name, "app_store_id"): + return "497799835" + case strings.Contains(name, "team_name") || strings.Contains(name, "fleet_name"): + return tag + "Marketing" + case strings.Contains(name, "user_full") || strings.Contains(name, "actor_full"): + return tag + "Example User" + case strings.Contains(name, "user_email") || name == "email": + return tag + "user@example.com" + case strings.Contains(name, "user_name"): + return tag + "user@example.com" + case strings.Contains(name, "policy_name"): + return tag + "Failing policy" + case strings.Contains(name, "profile_name"): + return tag + "Example profile" + case strings.Contains(name, "label_name"): + return tag + "Example label" + case strings.Contains(name, "script_name"): + return tag + "example.sh" + case strings.Contains(name, "location"): + return tag + "United States" + case strings.Contains(name, "name"): + return tag + "Example name" + default: + return tag + "example" + } +} + +// setExampleFields walks the activity's struct fields and assigns +// deterministic example values to anything left at its zero value. Hosts +// get the configured seed host id wired in; runTag is appended to +// name-like strings via stringExample. +func setExampleFields(activity any, hostID uint, runTag string) { + v := reflect.ValueOf(activity) + if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct { + return + } + s := v.Elem() + t := s.Type() + for i := 0; i < t.NumField(); i++ { + f := s.Field(i) + if !f.CanSet() { + continue + } + ft := t.Field(i) + jsonTag := strings.Split(ft.Tag.Get("json"), ",")[0] + nameLower := strings.ToLower(jsonTag) + if nameLower == "" { + nameLower = strings.ToLower(ft.Name) + } + + switch f.Kind() { + case reflect.String: + if f.String() == "" { + f.SetString(stringExample(jsonTag, ft.Name, runTag)) + } + case reflect.Bool: + // Self-service software activities are flipped to true so the + // passive-voice rendering can be inspected. + if nameLower == "self_service" { + f.SetBool(true) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if f.Uint() == 0 { + if strings.Contains(nameLower, "host_id") { + f.SetUint(uint64(hostID)) + } else { + f.SetUint(1) + } + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if f.Int() == 0 { + f.SetInt(1) + } + case reflect.Ptr: + if !f.IsNil() { + continue + } + elem := f.Type().Elem() + switch elem.Kind() { + case reflect.String: + val := stringExample(jsonTag, ft.Name, runTag) + f.Set(reflect.ValueOf(&val)) + case reflect.Bool: + val := false + f.Set(reflect.ValueOf(&val)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val := uint(1) + if strings.Contains(nameLower, "host_id") { + val = hostID + } + ptr := reflect.New(elem) + ptr.Elem().SetUint(uint64(val)) + f.Set(ptr) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + ptr := reflect.New(elem) + ptr.Elem().SetInt(1) + f.Set(ptr) + } + case reflect.Slice: + if !f.IsNil() { + continue + } + if ft.Name == "HostIDs" || nameLower == "host_ids" { + f.Set(reflect.ValueOf([]uint{hostID})) + } + } + } +} + +// Activity category names accepted by Activities.Category. Keep in sync with +// activityTemplatesByCategory; ActivityCategories is the public-facing list +// used by the cobra subcommands. +const ( + CategoryAll = "all" + CategorySettings = "settings" + CategoryProfiles = "profiles" + CategoryScripts = "scripts" + CategorySoftware = "software" + CategoryHosts = "hosts" + CategoryUsers = "users" + CategoryTeams = "teams" + CategoryPolicies = "policies" + CategoryQueries = "queries" + CategoryLabels = "labels" + CategoryCertificates = "certificates" +) + +// ActivityCategories is the ordered list of valid Category values, excluding +// "all". The activities cobra command uses this to register one subcommand +// per category and to validate user input. +var ActivityCategories = []string{ + CategorySettings, + CategoryProfiles, + CategoryScripts, + CategorySoftware, + CategoryHosts, + CategoryUsers, + CategoryTeams, + CategoryPolicies, + CategoryQueries, + CategoryLabels, + CategoryCertificates, +} + +// activityTemplatesByCategory maps each category to its activity templates. +// Every activity type in server/fleet/activities.go belongs to exactly one +// category so `dibble activities all` writes one of each. +// +// To regenerate the full template list when upstream adds new activities, +// from the repo root: +// +// grep -E "^func \(a Activity[A-Za-z]+\) ActivityName" server/fleet/activities.go \ +// | sed 's/^func (a \(Activity[A-Za-z]*\)) ActivityName.*/\tfleet.\1{},/' \ +// | sort -u +// +// then sort the new entries into the right bucket below. +var activityTemplatesByCategory = map[string][]fleet.ActivityDetails{ + CategorySettings: { + fleet.ActivityTypeEnabledActivityAutomations{}, + fleet.ActivityTypeEditedActivityAutomations{}, + fleet.ActivityTypeDisabledActivityAutomations{}, + fleet.ActivityTypeEditedAgentOptions{}, + fleet.ActivityTypeEditedMacOSMinVersion{}, + fleet.ActivityTypeEnabledMacosUpdateNewHosts{}, + fleet.ActivityTypeDisabledMacosUpdateNewHosts{}, + fleet.ActivityTypeEditedWindowsUpdates{}, + fleet.ActivityTypeEditedIOSMinVersion{}, + fleet.ActivityTypeEditedIPadOSMinVersion{}, + fleet.ActivityTypeEnabledMacosDiskEncryption{}, + fleet.ActivityTypeDisabledMacosDiskEncryption{}, + fleet.ActivityTypeEnabledRecoveryLockPasswords{}, + fleet.ActivityTypeDisabledRecoveryLockPasswords{}, + fleet.ActivityTypeEnabledGitOpsMode{}, + fleet.ActivityTypeDisabledGitOpsMode{}, + fleet.ActivityTypeEnabledGitOpsException{}, + fleet.ActivityTypeDisabledGitOpsException{}, + fleet.ActivityTypeEnabledHistoricalDataset{}, + fleet.ActivityTypeDisabledHistoricalDataset{}, + fleet.ActivityTypeEnabledMacosSetupEndUserAuth{}, + fleet.ActivityTypeDisabledMacosSetupEndUserAuth{}, + fleet.ActivityTypeEnabledWindowsMDM{}, + fleet.ActivityTypeDisabledWindowsMDM{}, + fleet.ActivityTypeEnabledWindowsMDMMigration{}, + fleet.ActivityTypeDisabledWindowsMDMMigration{}, + fleet.ActivityTypeEnabledAndroidMDM{}, + fleet.ActivityTypeDisabledAndroidMDM{}, + fleet.ActivityTypeChangedOrgLogo{}, + fleet.ActivityTypeDeletedOrgLogo{}, + fleet.ActivityEnabledVPP{}, + fleet.ActivityDisabledVPP{}, + fleet.ActivityTypeAddedConditionalAccessIntegrationMicrosoft{}, + fleet.ActivityTypeDeletedConditionalAccessIntegrationMicrosoft{}, + fleet.ActivityTypeAddedConditionalAccessOkta{}, + fleet.ActivityTypeDeletedConditionalAccessOkta{}, + fleet.ActivityTypeEnabledConditionalAccessAutomations{}, + fleet.ActivityTypeDisabledConditionalAccessAutomations{}, + fleet.ActivityTypeUpdateConditionalAccessBypass{}, + fleet.ActivityTypeAddedMicrosoftEntraTenant{}, + fleet.ActivityTypeDeletedMicrosoftEntraTenant{}, + fleet.ActivityTypeEditedEnrollSecrets{}, + fleet.ActivityCreatedCustomVariable{}, + fleet.ActivityDeletedCustomVariable{}, + fleet.ActivityEditedSetupExperienceSoftware{}, + }, + CategoryProfiles: { + fleet.ActivityTypeCreatedMacosProfile{}, + fleet.ActivityTypeDeletedMacosProfile{}, + fleet.ActivityTypeEditedMacosProfile{}, + fleet.ActivityTypeCreatedWindowsProfile{}, + fleet.ActivityTypeDeletedWindowsProfile{}, + fleet.ActivityTypeEditedWindowsProfile{}, + fleet.ActivityTypeCreatedDeclarationProfile{}, + fleet.ActivityTypeDeletedDeclarationProfile{}, + fleet.ActivityTypeEditedDeclarationProfile{}, + fleet.ActivityTypeCreatedAndroidProfile{}, + fleet.ActivityTypeDeletedAndroidProfile{}, + fleet.ActivityTypeEditedAndroidProfile{}, + fleet.ActivityTypeResentConfigurationProfile{}, + fleet.ActivityTypeResentConfigurationProfileBatch{}, + fleet.ActivityTypeChangedMacosSetupAssistant{}, + fleet.ActivityTypeDeletedMacosSetupAssistant{}, + fleet.ActivityTypeAddedBootstrapPackage{}, + fleet.ActivityTypeDeletedBootstrapPackage{}, + fleet.ActivityTypeFailedEnrollmentProfileRenewal{}, + }, + CategoryScripts: { + fleet.ActivityTypeRanScript{}, + fleet.ActivityTypeAddedScript{}, + fleet.ActivityTypeUpdatedScript{}, + fleet.ActivityTypeDeletedScript{}, + fleet.ActivityTypeEditedScript{}, + fleet.ActivityTypeCanceledRunScript{}, + fleet.ActivityTypeRanScriptBatch{}, + fleet.ActivityTypeBatchScriptScheduled{}, + fleet.ActivityTypeBatchScriptCanceled{}, + }, + CategorySoftware: { + fleet.ActivityTypeInstalledSoftware{}, + fleet.ActivityTypeUninstalledSoftware{}, + fleet.ActivityTypeAddedSoftware{}, + fleet.ActivityTypeEditedSoftware{}, + fleet.ActivityTypeDeletedSoftware{}, + fleet.ActivityTypeCanceledInstallSoftware{}, + fleet.ActivityTypeCanceledUninstallSoftware{}, + fleet.ActivityAddedAppStoreApp{}, + fleet.ActivityDeletedAppStoreApp{}, + fleet.ActivityInstalledAppStoreApp{}, + fleet.ActivityEditedAppStoreApp{}, + fleet.ActivityTypeCanceledInstallAppStoreApp{}, + fleet.ActivityTypeCanceledSetupExperience{}, + }, + CategoryHosts: { + fleet.ActivityTypeDeletedHost{}, + fleet.ActivityTypeFleetEnrolled{}, + fleet.ActivityTypeMDMEnrolled{}, + fleet.ActivityTypeMDMUnenrolled{}, + fleet.ActivityTypeLockedHost{}, + fleet.ActivityTypeUnlockedHost{}, + fleet.ActivityTypeWipedHost{}, + fleet.ActivityTypeWipeFailedHost{}, + fleet.ActivityTypeReadHostDiskEncryptionKey{}, + fleet.ActivityTypeEscrowedDiskEncryptionKey{}, + fleet.ActivityTypeViewedHostRecoveryLockPassword{}, + fleet.ActivityTypeSetHostRecoveryLockPassword{}, + fleet.ActivityTypeRotatedHostRecoveryLockPassword{}, + fleet.ActivityTypeCreatedManagedLocalAccount{}, + fleet.ActivityTypeViewedManagedLocalAccount{}, + fleet.ActivityTypeEnabledManagedLocalAccount{}, + fleet.ActivityTypeDisabledManagedLocalAccount{}, + fleet.ActivityTypeRotatedManagedLocalAccountPassword{}, + fleet.ActivityTypeFailedToRotateManagedLocalAccountPassword{}, + fleet.ActivityTypeHostBypassedConditionalAccess{}, + fleet.ActivityTypeClearedPasscode{}, + fleet.ActivityTypeEditedHostIdpData{}, + }, + CategoryUsers: { + fleet.ActivityTypeUserAddedBySSO{}, + fleet.ActivityTypeUserLoggedIn{}, + fleet.ActivityTypeUserFailedLogin{}, + fleet.ActivityTypeCreatedUser{}, + fleet.ActivityTypeDeletedUser{}, + fleet.ActivityTypeChangedUserGlobalRole{}, + fleet.ActivityTypeDeletedUserGlobalRole{}, + fleet.ActivityTypeChangedUserTeamRole{}, + fleet.ActivityTypeDeletedUserTeamRole{}, + }, + CategoryTeams: { + fleet.ActivityTypeCreatedTeam{}, + fleet.ActivityTypeDeletedTeam{}, + fleet.ActivityTypeAppliedSpecTeam{}, + fleet.ActivityTypeTransferredHostsToTeam{}, + }, + CategoryPolicies: { + fleet.ActivityTypeCreatedPolicy{}, + fleet.ActivityTypeEditedPolicy{}, + fleet.ActivityTypeDeletedPolicy{}, + fleet.ActivityTypeAppliedSpecPolicy{}, + }, + CategoryQueries: { + fleet.ActivityTypeCreatedSavedQuery{}, + fleet.ActivityTypeEditedSavedQuery{}, + fleet.ActivityTypeDeletedSavedQuery{}, + fleet.ActivityTypeDeletedMultipleSavedQuery{}, + fleet.ActivityTypeAppliedSpecSavedQuery{}, + fleet.ActivityTypeLiveQuery{}, + fleet.ActivityTypeCreatedPack{}, + fleet.ActivityTypeEditedPack{}, + fleet.ActivityTypeDeletedPack{}, + fleet.ActivityTypeAppliedSpecPack{}, + }, + CategoryLabels: { + fleet.ActivityTypeCreatedLabel{}, + fleet.ActivityTypeEditedLabel{}, + fleet.ActivityTypeDeletedLabel{}, + }, + CategoryCertificates: { + fleet.ActivityAddedNDESSCEPProxy{}, + fleet.ActivityDeletedNDESSCEPProxy{}, + fleet.ActivityEditedNDESSCEPProxy{}, + fleet.ActivityAddedCustomSCEPProxy{}, + fleet.ActivityDeletedCustomSCEPProxy{}, + fleet.ActivityEditedCustomSCEPProxy{}, + fleet.ActivityAddedDigiCert{}, + fleet.ActivityDeletedDigiCert{}, + fleet.ActivityEditedDigiCert{}, + fleet.ActivityAddedHydrant{}, + fleet.ActivityDeletedHydrant{}, + fleet.ActivityEditedHydrant{}, + fleet.ActivityAddedCustomESTProxy{}, + fleet.ActivityDeletedCustomESTProxy{}, + fleet.ActivityEditedCustomESTProxy{}, + fleet.ActivityAddedSmallstep{}, + fleet.ActivityDeletedSmallstep{}, + fleet.ActivityEditedSmallstep{}, + fleet.ActivityTypeAddedCertificate{}, + fleet.ActivityTypeDeletedCertificate{}, + fleet.ActivityTypeInstalledCertificate{}, + fleet.ActivityTypeResentCertificate{}, + fleet.ActivityTypeEditedAndroidCertificate{}, + }, +} + +// templatesForCategory returns the templates a single category covers, or +// every template (in a stable order) when category == "" or "all". +func templatesForCategory(category string) ([]fleet.ActivityDetails, error) { + if category == "" || category == CategoryAll { + out := make([]fleet.ActivityDetails, 0, 200) + for _, cat := range ActivityCategories { + out = append(out, activityTemplatesByCategory[cat]...) + } + return out, nil + } + tmpls, ok := activityTemplatesByCategory[category] + if !ok { + return nil, fmt.Errorf("unknown activity category %q (valid: all, %s)", + category, strings.Join(ActivityCategories, ", ")) + } + return tmpls, nil +} + +// insertActivity writes one row to activity_past plus one row per host id +// reported by the activity into activity_host_past. Both writes go through +// a single transaction so a host-mapping failure can't leave behind a +// partially-seeded activity row. Returns the new activity_past.id. +func insertActivity( + ctx context.Context, db *sql.DB, + actorID uint, actorName, actorEmail string, + activity fleet.ActivityDetails, +) (int64, error) { + details, err := json.Marshal(activity) + if err != nil { + return 0, fmt.Errorf("marshal %T: %w", activity, err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + // Defer a rollback that's a no-op once we've committed. + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + const insert = `INSERT INTO activity_past + (user_id, user_name, user_email, activity_type, details, fleet_initiated) + VALUES (?, ?, ?, ?, ?, 0)` + res, err := tx.ExecContext(ctx, insert, + actorID, actorName, actorEmail, activity.ActivityName(), details) + if err != nil { + return 0, fmt.Errorf("insert %s: %w", activity.ActivityName(), err) + } + actID, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("last insert id for %s: %w", activity.ActivityName(), err) + } + + if h, ok := activity.(hostIDer); ok { + ids := h.HostIDs() + if len(ids) > 0 { + const insertHost = `INSERT INTO activity_host_past (host_id, activity_id) VALUES (?, ?)` + for _, hid := range ids { + if _, err := tx.ExecContext(ctx, insertHost, hid, actID); err != nil { + return 0, fmt.Errorf("insert activity_host_past %d/%d: %w", hid, actID, err) + } + } + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit activity %s: %w", activity.ActivityName(), err) + } + committed = true + return actID, nil +} + +// Activities writes a fresh batch of fake activities to MySQL on every call. +// All user-facing name fields are prefixed with "*" and tagged with the +// current run id so seeded rows are obvious in the UI and don't conflate +// across runs. +func Activities(ctx context.Context, log Logger, opt ActivitiesOptions) Result { + res := Result{Entity: "activities"} + if opt.DSN == "" { + res.Errors = append(res.Errors, errors.New("activities: empty DSN")) + return res + } + if opt.Batches <= 0 { + opt.Batches = 1 + } + if opt.HostID == 0 { + opt.HostID = 1 + } + if opt.ActorName == "" { + opt.ActorName = "*Dibble Admin" + } + if opt.ActorEmail == "" { + opt.ActorEmail = "*admin@example.com" + } + if opt.ActorID == 0 { + opt.ActorID = 1 + } + + dsn, err := mysqlDSN(opt.DSN, false) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err)) + return res + } + db, err := sql.Open("mysql", dsn) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err)) + return res + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err)) + return res + } + + // Verify the actor user exists; activity_past.user_id has an FK that + // would otherwise fire on insert with a confusing error. + var actorExists bool + row := db.QueryRowContext(ctx, "SELECT 1 FROM users WHERE id = ?", opt.ActorID) + if err := row.Scan(&actorExists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + res.Errors = append(res.Errors, + fmt.Errorf("actor user id=%d not found in users table — pass -actor-id or seed users first", opt.ActorID)) + return res + } + res.Errors = append(res.Errors, fmt.Errorf("check actor user: %w", err)) + return res + } + + templates, err := templatesForCategory(opt.Category) + if err != nil { + res.Errors = append(res.Errors, err) + return res + } + + for b := 0; b < opt.Batches; b++ { + runTag := fmt.Sprintf("%d-%d", time.Now().UnixNano()%1_000_000, b+1) + for _, tmpl := range templates { + ptr := reflect.New(reflect.TypeOf(tmpl)) + ptr.Elem().Set(reflect.ValueOf(tmpl)) + setExampleFields(ptr.Interface(), opt.HostID, runTag) + filled := ptr.Elem().Interface().(fleet.ActivityDetails) + if _, err := insertActivity(ctx, db, + opt.ActorID, opt.ActorName, opt.ActorEmail, filled); err != nil { + res.Errors = append(res.Errors, err) + continue + } + res.Created++ + } + category := opt.Category + if category == "" { + category = CategoryAll + } + log.Printf("activities: seeded batch %d/%d (category=%s tag=%s)", + b+1, opt.Batches, category, runTag) + } + return res +} diff --git a/tools/dibble/pkg/seed/cas.go b/tools/dibble/pkg/seed/cas.go new file mode 100644 index 0000000000..eaa48904d7 --- /dev/null +++ b/tools/dibble/pkg/seed/cas.go @@ -0,0 +1,230 @@ +package seed + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// CAOptions configures the CA seeder. Like the activity seeder it writes +// directly to MySQL (bypassing the service layer) so we don't have to spin +// up real SCEP / DigiCert / NDES / EST endpoints just to get past URL +// validation. +// +// Encrypted secret columns (api_token_encrypted, password_encrypted, +// challenge_encrypted, client_secret_encrypted) are intentionally left +// NULL. The rows show up in the UI list so reviewers can see the CA shape, +// but any actual request_certificate call will fail — which is the point. +// All user-facing names are prefixed with "*" and tagged with the run id +// so seeded rows are obvious and don't collide across runs. +type CAOptions struct { + DSN string + Count int +} + +// caFakePrefix marks every CA name dibble writes so it's obvious in the UI +// that the CA was planted by dibble and isn't expected to actually issue +// certificates. The "*" character isn't allowed by the service-layer name +// validator (^\w+$), which is fine — we bypass the service layer and the +// DB column is just a VARCHAR. +const caFakePrefix = "*" + +// NDES is special: Fleet hardcodes the name to "NDES" and the (type, name) +// unique index allows only one row. We can't prefix it; we just insert it +// once per run and let duplicate-key errors become skips on re-runs. +const ndesName = "NDES" + +// CAs writes a fresh batch of fake certificate authorities to MySQL. Each +// "batch" writes one row per non-NDES CA type (custom_scep_proxy, +// custom_est_proxy, digicert, hydrant, smallstep) plus a single NDES row +// at the start of the run. Count controls the number of batches. +func CAs(ctx context.Context, log Logger, opt CAOptions) Result { + res := Result{Entity: "cas"} + if opt.DSN == "" { + res.Errors = append(res.Errors, errors.New("cas: empty DSN")) + return res + } + if opt.Count <= 0 { + opt.Count = 1 + } + + dsn, err := mysqlDSN(opt.DSN, false) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err)) + return res + } + db, err := sql.Open("mysql", dsn) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err)) + return res + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err)) + return res + } + + runTag := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000) + + // NDES first — single insert, skipped if it already exists. + switch err := insertCA(ctx, db, ndesRow()); { + case err == nil: + res.Created++ + log.Printf("ca: inserted ndes_scep_proxy/%s", ndesName) + case isDuplicateCAError(err): + res.Skipped++ + log.Printf("ca: skip ndes_scep_proxy/%s (already exists)", ndesName) + default: + res.Errors = append(res.Errors, fmt.Errorf("insert NDES: %w", err)) + } + + for b := 0; b < opt.Count; b++ { + for _, build := range caTemplates { + row := build(runTag, b+1) + switch err := insertCA(ctx, db, row); { + case err == nil: + res.Created++ + log.Printf("ca: inserted %s/%s", row.Type, derefStr(row.Name)) + case isDuplicateCAError(err): + res.Skipped++ + log.Printf("ca: skip %s/%s (already exists)", row.Type, derefStr(row.Name)) + default: + res.Errors = append(res.Errors, fmt.Errorf("insert %s/%s: %w", row.Type, derefStr(row.Name), err)) + } + } + } + log.Printf("cas: seeded %d batch(es) (tag=%s)", opt.Count, runTag) + return res +} + +// caRow mirrors the certificate_authorities table columns. Encrypted blob +// fields are left as zero-value []byte (NULL in SQL) so dibble doesn't need +// the server's private key. +type caRow struct { + Type string + Name *string + URL *string + APITokenEncrypted []byte + ProfileID *string + CertificateCommonName *string + CertificateUserPrincipalNames []byte // JSON + CertificateSeatID *string + AdminURL *string + ChallengeURL *string + Username *string + PasswordEncrypted []byte + ChallengeEncrypted []byte + ClientID *string + ClientSecretEncrypted []byte +} + +// caTemplates returns one row builder per non-NDES CA type. Each builder +// takes the run tag and a per-batch sequence number and returns a fully +// populated row with a unique (*-prefixed) name. +var caTemplates = []func(runTag string, seq int) caRow{ + func(runTag string, seq int) caRow { + name := fmt.Sprintf("%s%s_scep_%d", caFakePrefix, runTag, seq) + url := "https://fake.example.com/scep" + return caRow{Type: "custom_scep_proxy", Name: &name, URL: &url} + }, + func(runTag string, seq int) caRow { + name := fmt.Sprintf("%s%s_est_%d", caFakePrefix, runTag, seq) + url := "https://fake.example.com/.well-known/est" + user := "*est-user" + return caRow{Type: "custom_est_proxy", Name: &name, URL: &url, Username: &user} + }, + func(runTag string, seq int) caRow { + name := fmt.Sprintf("%s%s_digicert_%d", caFakePrefix, runTag, seq) + url := "https://one.digicert.com" + profileID := "00000000-0000-0000-0000-000000000000" + cn := "*dibble-cn" + seatID := "*dibble-seat" + upns, _ := json.Marshal([]string{"*user@example.com"}) + return caRow{ + Type: "digicert", + Name: &name, + URL: &url, + ProfileID: &profileID, + CertificateCommonName: &cn, + CertificateUserPrincipalNames: upns, + CertificateSeatID: &seatID, + } + }, + func(runTag string, seq int) caRow { + name := fmt.Sprintf("%s%s_hydrant_%d", caFakePrefix, runTag, seq) + url := "https://fake.example.com/hydrant" + clientID := "*dibble-client" + return caRow{Type: "hydrant", Name: &name, URL: &url, ClientID: &clientID} + }, + func(runTag string, seq int) caRow { + name := fmt.Sprintf("%s%s_smallstep_%d", caFakePrefix, runTag, seq) + url := "https://fake.example.com/scep" + challengeURL := "https://fake.example.com/challenge" + user := "*smallstep-user" + return caRow{ + Type: "smallstep", + Name: &name, + URL: &url, + ChallengeURL: &challengeURL, + Username: &user, + } + }, +} + +func ndesRow() caRow { + name := ndesName + url := "https://fake.example.com/certsrv/mscep/mscep.dll" + adminURL := "https://fake.example.com/certsrv/mscep_admin/" + user := "*ndes-admin" + return caRow{ + Type: "ndes_scep_proxy", + Name: &name, + URL: &url, + AdminURL: &adminURL, + Username: &user, + } +} + +const insertCAStmt = `INSERT INTO certificate_authorities ( + type, name, url, + api_token_encrypted, profile_id, certificate_common_name, + certificate_user_principal_names, certificate_seat_id, + admin_url, challenge_url, username, + password_encrypted, challenge_encrypted, + client_id, client_secret_encrypted +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + +func insertCA(ctx context.Context, db *sql.DB, r caRow) error { + _, err := db.ExecContext(ctx, insertCAStmt, + r.Type, r.Name, r.URL, + r.APITokenEncrypted, r.ProfileID, r.CertificateCommonName, + r.CertificateUserPrincipalNames, r.CertificateSeatID, + r.AdminURL, r.ChallengeURL, r.Username, + r.PasswordEncrypted, r.ChallengeEncrypted, + r.ClientID, r.ClientSecretEncrypted, + ) + return err +} + +// isDuplicateCAError reports whether err is a (type, name) unique-key +// collision, so the caller can count it as Skipped instead of Errored. +func isDuplicateCAError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "idx_ca_type_name") || strings.Contains(msg, "Duplicate entry") +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/tools/dibble/pkg/seed/data/installers/7z2601-arm64.exe b/tools/dibble/pkg/seed/data/installers/7z2601-arm64.exe new file mode 100644 index 0000000000..86bbf63ec1 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/7z2601-arm64.exe differ diff --git a/tools/dibble/pkg/seed/data/installers/7z2601-x64.exe b/tools/dibble/pkg/seed/data/installers/7z2601-x64.exe new file mode 100644 index 0000000000..c0c7f92c08 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/7z2601-x64.exe differ diff --git a/tools/dibble/pkg/seed/data/installers/7z2601.exe b/tools/dibble/pkg/seed/data/installers/7z2601.exe new file mode 100644 index 0000000000..1e639e2e90 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/7z2601.exe differ diff --git a/tools/dibble/pkg/seed/data/installers/EchoApp.pkg b/tools/dibble/pkg/seed/data/installers/EchoApp.pkg new file mode 100644 index 0000000000..188c7c686d Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/EchoApp.pkg differ diff --git a/tools/dibble/pkg/seed/data/installers/dummy_installer.pkg b/tools/dibble/pkg/seed/data/installers/dummy_installer.pkg new file mode 100644 index 0000000000..41aa77748b Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/dummy_installer.pkg differ diff --git a/tools/dibble/pkg/seed/data/installers/emacs.deb b/tools/dibble/pkg/seed/data/installers/emacs.deb new file mode 100644 index 0000000000..90c58f1045 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/emacs.deb differ diff --git a/tools/dibble/pkg/seed/data/installers/ipa_test.ipa b/tools/dibble/pkg/seed/data/installers/ipa_test.ipa new file mode 100644 index 0000000000..177ef455f0 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/ipa_test.ipa differ diff --git a/tools/dibble/pkg/seed/data/installers/ipa_test2.ipa b/tools/dibble/pkg/seed/data/installers/ipa_test2.ipa new file mode 100644 index 0000000000..177ef455f0 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/ipa_test2.ipa differ diff --git a/tools/dibble/pkg/seed/data/installers/no_version.pkg b/tools/dibble/pkg/seed/data/installers/no_version.pkg new file mode 100644 index 0000000000..c649ebf17b Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/no_version.pkg differ diff --git a/tools/dibble/pkg/seed/data/installers/python-manager-26.2.msi b/tools/dibble/pkg/seed/data/installers/python-manager-26.2.msi new file mode 100644 index 0000000000..5424b63725 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/python-manager-26.2.msi differ diff --git a/tools/dibble/pkg/seed/data/installers/ruby.deb b/tools/dibble/pkg/seed/data/installers/ruby.deb new file mode 100644 index 0000000000..b8ac63e044 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/ruby.deb differ diff --git a/tools/dibble/pkg/seed/data/installers/ruby.rpm b/tools/dibble/pkg/seed/data/installers/ruby.rpm new file mode 100644 index 0000000000..e7020796da Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/ruby.rpm differ diff --git a/tools/dibble/pkg/seed/data/installers/ruby_arm64.deb b/tools/dibble/pkg/seed/data/installers/ruby_arm64.deb new file mode 100644 index 0000000000..b8ac63e044 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/ruby_arm64.deb differ diff --git a/tools/dibble/pkg/seed/data/installers/test.tar.gz b/tools/dibble/pkg/seed/data/installers/test.tar.gz new file mode 100644 index 0000000000..3371ba0d41 Binary files /dev/null and b/tools/dibble/pkg/seed/data/installers/test.tar.gz differ diff --git a/tools/dibble/pkg/seed/data/software-macos.csv b/tools/dibble/pkg/seed/data/software-macos.csv new file mode 100644 index 0000000000..2964d92471 --- /dev/null +++ b/tools/dibble/pkg/seed/data/software-macos.csv @@ -0,0 +1,14 @@ +name,version,source,bundle_identifier,release,vendor_old,arch,vendor +Example macos Software,1.0,apps,com.example.software,Stable,ExampleVendorOld,x86_64,ExampleVendor +Another macos Software,2.0,apps,com.another.software,Beta,AnotherVendorOld,arm64,AnotherVendor +Kubernetes Minikube 1.26.0,1.26.0,apps,,,,, +LibreOffice,7.4.0,apps,,,,, +Open Ticket Request System (OTRS) ITSM,6.0.32,apps,,,,, +Open Ticket Request System (OTRS),7.0.40,apps,,,,, +YugabyteDB,2.1.10,apps,,,,, +HYPR Workforce Access (WFA),7.7.1,apps,,,,, +MongoDB,4.4.4,apps,,,,, +ij_network_tool,4.7.3,apps,,,,, +Mozilla Thunderbird,115.2.0,apps,,,,, +Google Chrome,116.0.5845.109,apps,,,,, +Mozilla Firefox,1.5.2,apps,,,,, \ No newline at end of file diff --git a/tools/dibble/pkg/seed/data/software-ubuntu.csv b/tools/dibble/pkg/seed/data/software-ubuntu.csv new file mode 100644 index 0000000000..7face9449e --- /dev/null +++ b/tools/dibble/pkg/seed/data/software-ubuntu.csv @@ -0,0 +1,9 @@ +name,version,source,bundle_identifier,release,vendor_old,arch,vendor +example-ubuntu-software,1.0,Package (deb),,Stable,ExampleVendorOld,x86_64,ExampleVendor +another-ubuntu-software,2.0,Package (deb),,Beta,AnotherVendorOld,arm64,AnotherVendor +LibreOffice,7.4.0,Package (deb),,,,, +Open Ticket Request System (OTRS) ITSM,6.0.32,Package (deb),,,,, +Open Ticket Request System (OTRS),7.0.40,Package (deb),,,,, +YugabyteDB,2.1.10,Package (deb),,,,, +MongoDB,4.4.4,Package (deb),,,,, +Mozilla Firefox,1.5.2,Package (deb),,,,, \ No newline at end of file diff --git a/tools/dibble/pkg/seed/data/software-win.csv b/tools/dibble/pkg/seed/data/software-win.csv new file mode 100644 index 0000000000..ae66112ec1 --- /dev/null +++ b/tools/dibble/pkg/seed/data/software-win.csv @@ -0,0 +1,12 @@ +name,version,source,bundle_identifier,release,vendor_old,arch,vendor +Example Windows Software,1.0,programs,,Stable,ExampleVendorOld,x86_64,ExampleVendor +Another Windows Software,2.0,programs,,Beta,AnotherVendorOld,arm64,AnotherVendor +Open Ticket Request System (OTRS) ITSM,6.0.32,programs,,,,, +Open Ticket Request System (OTRS),7.0.40,programs,,,,, +YugabyteDB,2.1.10,programs,,,,, +HYPR Workforce Access (WFA),7.7.1,programs,,,,, +MongoDB 4.4.4,4.4.4,programs,,,,, +Microsoft Exchange Server,5.5,programs,,,,, +McAfee ePolicy Orchestrator,2.5.1,programs,,,,, +Microsoft Internet Explorer,5.5,programs,,,,, +Mozilla Firefox,1.5.2,programs,,,,, \ No newline at end of file diff --git a/tools/dibble/pkg/seed/enroll_secrets.go b/tools/dibble/pkg/seed/enroll_secrets.go new file mode 100644 index 0000000000..42cb562e7c --- /dev/null +++ b/tools/dibble/pkg/seed/enroll_secrets.go @@ -0,0 +1,49 @@ +package seed + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// EnrollSecrets seeds a per-team enroll secret — the credential a fleetd +// agent presents to join a team. This is distinct from "Fleet secrets" / +// secret variables, which are template variables substituted into profiles +// and scripts; those are a separate seeder (not yet implemented). +// +// The global enroll secret is left alone because changing it would +// invalidate any already-enrolled host. Rotate it deliberately, not via seed. +func EnrollSecrets(c Client, log Logger, teams []Team) Result { + res := Result{Entity: "enroll-secrets"} + for _, t := range teams { + secret := randomEnrollSecret() + body := map[string]any{ + "secrets": []map[string]string{{"secret": secret}}, + } + // PATCH replaces the team's enroll-secret list with this one. Find + // them in the UI under Settings → [team] → Add hosts → Show enroll secret. + err := c.Patch(fmt.Sprintf("/api/latest/fleet/fleets/%d/secrets", t.ID), body, nil) + switch { + case err == nil: + res.Created++ + // Don't log the secret itself — credentials in logs/shell + // history are an avoidable leak. Find seeded values in the + // UI under Settings → [team] → Add hosts → Show enroll secret. + log.Printf("enroll secret created for team=%s (id=%d)", t.Name, t.ID) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + return res +} + +func randomEnrollSecret() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Vanishingly unlikely; fall back to a fixed-but-clearly-fake value. + return "dibble-fallback-secret" + } + return "dibble-" + hex.EncodeToString(b[:]) +} diff --git a/tools/dibble/pkg/seed/idp.go b/tools/dibble/pkg/seed/idp.go new file mode 100644 index 0000000000..989fb194d8 --- /dev/null +++ b/tools/dibble/pkg/seed/idp.go @@ -0,0 +1,309 @@ +package seed + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// IDPOptions configures the IDP seeder. DSN points at a Fleet MySQL instance; +// like vulns and activities, this seeder writes directly to MySQL because the +// IDP-linkage tables (mdm_idp_accounts, host_mdm_idp_accounts, scim_users, +// host_scim_user) are normally populated by the MDM enrollment and SCIM sync +// flows rather than any public API. +type IDPOptions struct { + DSN string + UserCount int // how many seeded users get an mdm_idp_accounts row + HostCount int // how many hosts get a host_mdm_idp_accounts assignment +} + +// idpUser is the subset of the GET /users response we care about. +type idpUser struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +// idpHost is the subset of the GET /hosts response we care about. +type idpHost struct { + ID uint `json:"id"` + UUID string `json:"uuid"` + Hostname string `json:"hostname"` +} + +// IDP seeds IDP linkage for the most-recently-created Fleet users so they +// surface on host detail pages. Users and hosts are fetched via the Fleet +// API (users most-recent first, so dibble-created users come up before the +// bootstrap admin); the IDP tables are written directly via the supplied +// MySQL DSN. +// +// Two layers are written per user: +// - mdm_idp_accounts (UUID-keyed) + host_mdm_idp_accounts (host UUID → +// account UUID). Used by the MDM enrollment flow. +// - scim_users (numeric id) + host_scim_user (numeric host id → scim user +// id). This is what the host details "User" card reads: GetEndUsers in +// server/fleet/hosts.go prefers SCIM and only falls back to host_emails +// for the username field, never populating IdpFullName from the legacy +// mdm_idp_accounts table. +// +// Assignment is round-robin: host[i] gets seeded_user[i % len(users)] so the +// host count can exceed the user count and the extras still pick up real +// IDP records. Idempotent: existing rows matched by their unique key (email +// for mdm_idp_accounts, user_name for scim_users, host_uuid/host_id for the +// linkage tables) are reused rather than re-inserted; linkage inserts use +// INSERT IGNORE. +func IDP(ctx context.Context, c Client, log Logger, opt IDPOptions) Result { + res := Result{Entity: "idp"} + if opt.UserCount <= 0 { + opt.UserCount = 3 + } + if opt.HostCount < 0 { + opt.HostCount = 0 + } + + users, err := fetchUsersForIDP(c, opt.UserCount) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("list users: %w", err)) + return res + } + if len(users) == 0 { + res.Errors = append(res.Errors, errors.New("no users found — run `dibble users` first")) + return res + } + + hosts, err := fetchHostsForIDP(c, opt.HostCount) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("list hosts: %w", err)) + return res + } + + dsn, err := mysqlDSN(opt.DSN, false) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err)) + return res + } + db, err := sql.Open("mysql", dsn) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err)) + return res + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err)) + return res + } + + // 1. Upsert mdm_idp_accounts + scim_users for each user. The two writes + // are paired: if either fails for a user, neither identity is retained + // for host assignment so the round-robin in step 2 stays aligned and + // host i's mdm_idp_account always points at the same identity as host + // i's scim_user. + seeded := make([]seededIDPUser, 0, len(users)) + for _, u := range users { + accUUID, accCreated, err := upsertIDPAccount(ctx, db, u) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("upsert idp account for %s: %w", u.Email, err)) + continue + } + scimID, scimCreated, err := upsertSCIMUser(ctx, db, u) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("upsert scim user for %s: %w", u.Email, err)) + continue + } + seeded = append(seeded, seededIDPUser{accountUUID: accUUID, scimUserID: scimID}) + + if accCreated { + res.Created++ + log.Printf("idp account %s <%s>", u.Name, u.Email) + } else { + res.Skipped++ + } + if scimCreated { + res.Created++ + log.Printf("scim user %s <%s>", u.Name, u.Email) + } else { + res.Skipped++ + } + } + + if len(seeded) == 0 { + res.Errors = append(res.Errors, errors.New("no idp or scim users created or found")) + return res + } + + // 2. Assign hosts (round-robin) to both linkage tables using the paired + // identities from step 1. + if len(hosts) == 0 && opt.HostCount > 0 { + log.Printf("idp: no hosts found — run osquery-perf to enroll some first") + } + for i, h := range hosts { + pair := seeded[i%len(seeded)] + + assigned, err := assignHostToIDPAccount(ctx, db, h.UUID, pair.accountUUID) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("assign host %s to idp account: %w", h.UUID, err)) + } else if assigned { + res.Created++ + log.Printf("idp host %s (%s) → account %s", h.Hostname, h.UUID, pair.accountUUID) + } else { + res.Skipped++ + } + + assigned, err = assignHostToSCIMUser(ctx, db, h.ID, pair.scimUserID) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("assign host %d to scim user: %w", h.ID, err)) + continue + } + if assigned { + res.Created++ + log.Printf("scim host %s (id=%d) → scim_user_id=%d", h.Hostname, h.ID, pair.scimUserID) + } else { + res.Skipped++ + } + } + + return res +} + +// seededIDPUser holds the IDs both IDP tables produced for a single user, so +// host assignments to mdm_idp_accounts and scim_users stay aligned. +type seededIDPUser struct { + accountUUID string + scimUserID uint +} + +// fetchUsersForIDP pulls up to `limit` users from the Fleet API, sorted +// most-recently-created first so dibble-seeded users surface ahead of the +// bootstrap admin. +func fetchUsersForIDP(c Client, limit int) ([]idpUser, error) { + var resp struct { + Users []idpUser `json:"users"` + } + path := fmt.Sprintf("/api/latest/fleet/users?order_key=created_at&order_direction=desc&per_page=%d", limit) + if err := c.Get(path, &resp); err != nil { + return nil, err + } + return resp.Users, nil +} + +// fetchHostsForIDP pulls up to `limit` hosts from the Fleet API. Returns an +// empty slice (not an error) when limit == 0 so callers can disable host +// assignment without a separate code path. +func fetchHostsForIDP(c Client, limit int) ([]idpHost, error) { + if limit == 0 { + return nil, nil + } + var resp struct { + Hosts []idpHost `json:"hosts"` + } + path := fmt.Sprintf("/api/latest/fleet/hosts?per_page=%d", limit) + if err := c.Get(path, &resp); err != nil { + return nil, err + } + return resp.Hosts, nil +} + +// upsertIDPAccount returns the account UUID for the given user's email. If a +// row already exists (matched by the unique email), its UUID is reused and +// created=false. Otherwise a new UUID is generated, inserted, and returned +// with created=true. +func upsertIDPAccount(ctx context.Context, db *sql.DB, u idpUser) (accountUUID string, created bool, err error) { + if err := db.QueryRowContext(ctx, + `SELECT uuid FROM mdm_idp_accounts WHERE email = ?`, u.Email, + ).Scan(&accountUUID); err == nil { + return accountUUID, false, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return "", false, err + } + + accountUUID = uuid.NewString() + if _, err := db.ExecContext(ctx, + `INSERT INTO mdm_idp_accounts (uuid, username, fullname, email) VALUES (?, ?, ?, ?)`, + accountUUID, u.Email, u.Name, u.Email, + ); err != nil { + return "", false, err + } + return accountUUID, true, nil +} + +// assignHostToIDPAccount inserts a host_mdm_idp_accounts row, returning +// assigned=true when the row was created and false when an existing +// assignment for this host was preserved (the table has a UNIQUE on +// host_uuid). +func assignHostToIDPAccount(ctx context.Context, db *sql.DB, hostUUID, accountUUID string) (assigned bool, err error) { + r, err := db.ExecContext(ctx, + `INSERT IGNORE INTO host_mdm_idp_accounts (host_uuid, account_uuid) VALUES (?, ?)`, + hostUUID, accountUUID, + ) + if err != nil { + return false, err + } + n, err := r.RowsAffected() + if err != nil { + return false, err + } + return n > 0, nil +} + +// upsertSCIMUser returns the scim_users.id for the given user, matched by +// user_name (UNIQUE). user_name is set to the user's email so the seeded row +// lines up with what the SCIM sync would produce. given_name / family_name +// come from splitting the user's full name on the first space — what +// ScimUser.DisplayName() concatenates back together for the "Full name (IdP)" +// field on the host details card. +func upsertSCIMUser(ctx context.Context, db *sql.DB, u idpUser) (id uint, created bool, err error) { + if err := db.QueryRowContext(ctx, + `SELECT id FROM scim_users WHERE user_name = ?`, u.Email, + ).Scan(&id); err == nil { + return id, false, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return 0, false, err + } + + given, family := splitFullName(u.Name) + r, err := db.ExecContext(ctx, + `INSERT INTO scim_users (user_name, given_name, family_name, active) VALUES (?, ?, ?, 1)`, + u.Email, given, family, + ) + if err != nil { + return 0, false, err + } + lastID, err := r.LastInsertId() + if err != nil { + return 0, false, err + } + return uint(lastID), true, nil +} + +// assignHostToSCIMUser inserts a host_scim_user row, returning assigned=true +// when the row was created. host_scim_user.host_id is the PRIMARY KEY, so an +// existing mapping for this host is preserved (INSERT IGNORE). +func assignHostToSCIMUser(ctx context.Context, db *sql.DB, hostID, scimUserID uint) (assigned bool, err error) { + r, err := db.ExecContext(ctx, + `INSERT IGNORE INTO host_scim_user (host_id, scim_user_id) VALUES (?, ?)`, + hostID, scimUserID, + ) + if err != nil { + return false, err + } + n, err := r.RowsAffected() + if err != nil { + return false, err + } + return n > 0, nil +} + +// splitFullName breaks a single-string name into SCIM's given/family parts. +// Single-word names go entirely into given_name so DisplayName() still +// renders them. +func splitFullName(full string) (given, family string) { + full = strings.TrimSpace(full) + if i := strings.IndexByte(full, ' '); i > 0 { + return full[:i], strings.TrimSpace(full[i+1:]) + } + return full, "" +} diff --git a/tools/dibble/pkg/seed/labels.go b/tools/dibble/pkg/seed/labels.go new file mode 100644 index 0000000000..1aafab4388 --- /dev/null +++ b/tools/dibble/pkg/seed/labels.go @@ -0,0 +1,41 @@ +package seed + +import ( + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Labels seeds dynamic (query-based) labels. Manual labels require live host +// identifiers, which dibble can't synthesize meaningfully — those should be +// created against a Fleet populated by osquery-perf. +func Labels(c Client, log Logger, theme themes.Theme, count int) Result { + res := Result{Entity: "labels"} + + labelQueries := []string{ + "SELECT 1 WHERE 1=0;", + "SELECT 1 FROM osquery_info;", + "SELECT 1 FROM os_version WHERE platform = 'darwin';", + "SELECT 1 FROM os_version WHERE platform = 'windows';", + "SELECT 1 FROM os_version WHERE platform IN ('ubuntu','rhel','centos','debian');", + } + + for i := 0; i < count; i++ { + n := themes.Pick(theme, "label", i) + body := map[string]any{ + "name": n.Name, + "query": labelQueries[i%len(labelQueries)], + "platform": "", + "description": "Seeded by dibble — " + n.Name, + } + err := c.Post("/api/latest/fleet/labels", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("label %q", n.Name) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + return res +} diff --git a/tools/dibble/pkg/seed/policies.go b/tools/dibble/pkg/seed/policies.go new file mode 100644 index 0000000000..44ba9123f0 --- /dev/null +++ b/tools/dibble/pkg/seed/policies.go @@ -0,0 +1,66 @@ +package seed + +import ( + "fmt" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Policies creates `count` global policies plus a handful per team. +// Each policy rotates a platform (darwin/windows/linux/all) so the seeded set +// exercises every cross-platform code path in the UI. +var seededPlatforms = []string{"", "darwin", "windows", "linux", "darwin,windows,linux"} + +// dummyPolicyQuery is a query that always returns "compliant" — enough for the +// UI to show the row, but never alarming on a fresh dev Fleet. +const dummyPolicyQuery = "SELECT 1 WHERE 1=1;" + +func Policies(c Client, log Logger, theme themes.Theme, teams []Team, count int) Result { + res := Result{Entity: "policies"} + + // Global policies. + for i := 0; i < count; i++ { + n := themes.Pick(theme, "policy", i) + body := map[string]any{ + "name": n.Name, + "query": dummyPolicyQuery, + "description": n.Desc, + "resolution": "Wave the dibble at the host until it complies.", + "platform": seededPlatforms[i%len(seededPlatforms)], + } + err := c.Post("/api/latest/fleet/policies", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("policy (global) %q", n.Name) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + + // Per-team policies — 2 per team. + for _, t := range teams { + for i := 0; i < 2; i++ { + n := themes.Pick(theme, "policy", count+i) + body := map[string]any{ + "name": fmt.Sprintf("%s — %s", n.Name, t.Name), + "query": dummyPolicyQuery, + "description": n.Desc, + "platform": seededPlatforms[i%len(seededPlatforms)], + } + err := c.Post(fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", t.ID), body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("policy (team=%s) %q", t.Name, body["name"]) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + } + return res +} diff --git a/tools/dibble/pkg/seed/profiles.go b/tools/dibble/pkg/seed/profiles.go new file mode 100644 index 0000000000..8ee15f6173 --- /dev/null +++ b/tools/dibble/pkg/seed/profiles.go @@ -0,0 +1,148 @@ +package seed + +import ( + cryptorand "crypto/rand" + _ "embed" + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Profiles seeds MDM configuration profiles. The same endpoint +// (/api/latest/fleet/configuration_profiles) accepts both Apple +// (.mobileconfig) and Windows (.xml) payloads — Fleet detects the platform +// from the content. +// +// The seeded profile content is parameterized: the PayloadIdentifier (Apple) +// or LocURI (Windows) is filled with a themed string so each profile is +// unique per seed run. + +//go:embed templates/profile.mobileconfig.tmpl +var appleProfileTmpl string + +//go:embed templates/profile.windows.xml.tmpl +var windowsProfileTmpl string + +// mdmConfigSubset is the slice of GET /config we use to decide which +// profile uploads to attempt. The full /config response is huge — only the +// per-platform "enabled and configured" flags are relevant here. +type mdmConfigSubset struct { + MDM struct { + EnabledAndConfigured bool `json:"enabled_and_configured"` // Apple + WindowsEnabledAndConfigured bool `json:"windows_enabled_and_configured"` // Windows + } `json:"mdm"` +} + +func Profiles(c Client, log Logger, theme themes.Theme, teams []Team, count int) Result { + res := Result{Entity: "profiles"} + + // Skip platforms whose MDM stack isn't turned on — otherwise every + // upload returns the same 400 "MDM features aren't turned on in Fleet" + // and floods the run output. + var cfg mdmConfigSubset + if err := c.Get("/api/latest/fleet/config", &cfg); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("check MDM config: %w", err)) + return res + } + appleOK := cfg.MDM.EnabledAndConfigured + winOK := cfg.MDM.WindowsEnabledAndConfigured + if !appleOK && !winOK { + log.Printf("profiles: MDM not enabled (neither Apple nor Windows), skipping") + return res + } + + postOne := func(platform string, teamID uint, idx int) { + n := themes.Pick(theme, "policy", idx) // reuse policy names — they make solid profile names + safeName := sanitizeProfileName(n.Name) + var content string + var filename string + switch platform { + case "apple": + content = strings.NewReplacer( + "{{NAME}}", safeName, + "{{IDENT}}", "dev.dibble."+safeName, + "{{UUID}}", randomUUIDv4(), + ).Replace(appleProfileTmpl) + filename = safeName + ".mobileconfig" + case "windows": + content = strings.NewReplacer( + "{{NAME}}", safeName, + "{{LOCURI}}", "./Vendor/Dibble/"+safeName, + ).Replace(windowsProfileTmpl) + filename = safeName + ".xml" + } + fields := map[string]string{} + if teamID > 0 { + // Fleet's renamed "team" → "fleet" — the multipart key is fleet_id. + fields["fleet_id"] = fmt.Sprintf("%d", teamID) + } + // Form field is "profile"; Fleet detects platform from the filename's extension. + files := []MultipartFile{{FieldName: "profile", Filename: filename, Content: []byte(content)}} + err := c.PostMultipart("/api/latest/fleet/configuration_profiles", fields, files, nil) + switch { + case err == nil: + res.Created++ + log.Printf("profile (%s, %s) %s", platform, scopeLabel(teamID), filename) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + + // Global profiles, alternating Apple / Windows — but only for the + // platforms whose MDM stack is enabled. + for i := 0; i < count; i++ { + if i%2 == 0 { + if appleOK { + postOne("apple", 0, i) + } + } else { + if winOK { + postOne("windows", 0, i) + } + } + } + // One Apple + one Windows per team, again gated by which stacks are on. + for _, t := range teams { + if appleOK { + postOne("apple", t.ID, 100) + } + if winOK { + postOne("windows", t.ID, 101) + } + } + return res +} + +// randomUUIDv4 returns a fresh RFC 4122 v4 UUID. Per-profile UUIDs prevent +// macOS from treating every seeded profile as the same payload (which would +// cause install/update collisions). +func randomUUIDv4() string { + var b [16]byte + if _, err := cryptorand.Read(b[:]); err != nil { + // Vanishingly unlikely; fall back to a clearly-fake-but-unique-ish + // value so callers can still spot seeded rows. + return "00000000-0000-0000-0000-000000000000" + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func sanitizeProfileName(s string) string { + r := strings.NewReplacer( + " ", "-", "/", "-", "\\", "-", ":", "-", + "'", "", `"`, "", ".", "-", + ) + return strings.Trim(r.Replace(strings.ToLower(s)), "-") +} + +func scopeLabel(teamID uint) string { + if teamID == 0 { + return "global" + } + return fmt.Sprintf("team=%d", teamID) +} diff --git a/tools/dibble/pkg/seed/reports.go b/tools/dibble/pkg/seed/reports.go new file mode 100644 index 0000000000..9484c32a73 --- /dev/null +++ b/tools/dibble/pkg/seed/reports.go @@ -0,0 +1,70 @@ +package seed + +import ( + "fmt" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Reports (formerly "queries") are saved SQL queries with an interval. We seed +// a mix of global and team-scoped reports, alternating intervals (0 = never, +// 60s, 3600s) so the schedule grid shows variety in the UI. +func Reports(c Client, log Logger, theme themes.Theme, teams []Team, count int) Result { + res := Result{Entity: "reports"} + + exampleSQL := []string{ + "SELECT version FROM osquery_info;", + "SELECT name FROM apps WHERE name LIKE '%Slack%';", + "SELECT pid, name FROM processes ORDER BY pid LIMIT 5;", + "SELECT * FROM users WHERE shell != '/usr/bin/false' LIMIT 10;", + "SELECT path, type FROM mounts WHERE path LIKE '/Volumes/%';", + } + intervals := []int{0, 60, 3600} + + for i := 0; i < count; i++ { + n := themes.Pick(theme, "policy", i) // reuse policy names — they read fine as queries too + body := map[string]any{ + "name": fmt.Sprintf("%s report", n.Name), + "description": n.Desc, + "query": exampleSQL[i%len(exampleSQL)], + "interval": intervals[i%len(intervals)], + "observer_can_run": i%2 == 0, + "platform": seededPlatforms[i%len(seededPlatforms)], + "logging": "snapshot", + } + err := c.Post("/api/latest/fleet/reports", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("report (global) %q", body["name"]) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + + // One report per team. + for _, t := range teams { + n := themes.Pick(theme, "policy", count) + teamID := t.ID + body := map[string]any{ + "name": fmt.Sprintf("%s — %s report", n.Name, t.Name), + "description": n.Desc, + "query": exampleSQL[0], + "interval": 0, + "team_id": teamID, + } + err := c.Post("/api/latest/fleet/reports", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("report (team=%s) %q", t.Name, body["name"]) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + return res +} diff --git a/tools/dibble/pkg/seed/scripts.go b/tools/dibble/pkg/seed/scripts.go new file mode 100644 index 0000000000..6bb60180a6 --- /dev/null +++ b/tools/dibble/pkg/seed/scripts.go @@ -0,0 +1,61 @@ +package seed + +import ( + "fmt" + "strings" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Scripts seeds saved scripts (global + per-team). The endpoint accepts a +// multipart upload with the script content as a file plus `team_id` (or +// none for global) as a form field. +// +// Script content varies by extension: .sh for macOS/Linux targets and .ps1 +// for Windows — the two extensions Fleet's scripts endpoint accepts (along +// with .py, which we don't seed). +func Scripts(c Client, log Logger, theme themes.Theme, teams []Team, count int) Result { + res := Result{Entity: "scripts"} + + exts := []string{".sh", ".ps1"} + body := func(ext, name string) string { + switch ext { + case ".ps1": + return fmt.Sprintf("# %s\nWrite-Host 'dibble was here'\n", name) + default: + return fmt.Sprintf("#!/usr/bin/env bash\n# %s\necho 'dibble was here'\n", name) + } + } + + postOne := func(scope string, teamID uint, i int) { + n := themes.Pick(theme, "script", i) + ext := exts[i%len(exts)] + name := strings.TrimSuffix(n.Name, ".sh") + name = strings.TrimSuffix(name, ".ps1") + ext + fields := map[string]string{} + if teamID > 0 { + // Fleet's renamed "team" → "fleet" — the multipart key is fleet_id. + fields["fleet_id"] = fmt.Sprintf("%d", teamID) + } + // Form field must be exactly "script"; filename carries the extension. + files := []MultipartFile{{FieldName: "script", Filename: name, Content: []byte(body(ext, n.Name))}} + err := c.PostMultipart("/api/latest/fleet/scripts", fields, files, nil) + switch { + case err == nil: + res.Created++ + log.Printf("script (%s) %s", scope, name) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + + for i := 0; i < count; i++ { + postOne("global", 0, i) + } + for _, t := range teams { + postOne("team="+t.Name, t.ID, 0) + } + return res +} diff --git a/tools/dibble/pkg/seed/seed.go b/tools/dibble/pkg/seed/seed.go new file mode 100644 index 0000000000..3fbb8ca3d3 --- /dev/null +++ b/tools/dibble/pkg/seed/seed.go @@ -0,0 +1,75 @@ +// Package seed contains the per-entity seeding logic that backs both the +// dibble subcommands and the interactive wizard. Keeping the logic here +// (instead of in the cmd_*.go cobra wrappers) makes it possible for the +// wizard to call the same functions and guarantees the two paths can't drift. +package seed + +import ( + "errors" + "fmt" +) + +// MultipartFile is one file part of a multipart upload. The FieldName is the +// form field (e.g. "script", "profile"); the Filename is what the server sees +// when it inspects the upload's name — important because Fleet uses extensions +// like .mobileconfig / .xml to detect MDM profile platforms. +type MultipartFile struct { + FieldName string + Filename string + Content []byte +} + +// Client is the subset of the dibble HTTP client that seeders need. Defined +// as an interface so tests can swap in a fake. +type Client interface { + Get(path string, out any) error + Post(path string, body any, out any) error + Patch(path string, body any, out any) error + Delete(path string) error + PostMultipart(path string, fields map[string]string, files []MultipartFile, out any) error +} + +// AlreadyExists is the sentinel returned by Client when the server reports a +// conflict. Seeders treat this as soft success. +type AlreadyExistsError interface { + error + IsAlreadyExists() bool +} + +// IsAlreadyExists reports whether err is an "already exists"-shaped error. +// Decoupled from the concrete client type via duck typing on Error() text so +// the seed package doesn't import the main package. +func IsAlreadyExists(err error) bool { + if err == nil { + return false + } + type ae interface{ IsAlreadyExists() bool } + var x ae + if errors.As(err, &x) { + return x.IsAlreadyExists() + } + return false +} + +// Result is what each seeder reports back so cmd_all and the wizard can +// print a tidy summary. +type Result struct { + Entity string + Created int + Skipped int // already-exists + Errors []error +} + +// Summary turns a Result into a one-line printable status. +func (r Result) Summary() string { + if len(r.Errors) > 0 { + return fmt.Sprintf("%s: %d created, %d skipped, %d errors", r.Entity, r.Created, r.Skipped, len(r.Errors)) + } + return fmt.Sprintf("%s: %d created, %d skipped", r.Entity, r.Created, r.Skipped) +} + +// Logger is a minimal writer interface so seeders can print progress lines +// without coupling to the main package. +type Logger interface { + Printf(format string, a ...any) +} diff --git a/tools/dibble/pkg/seed/software.go b/tools/dibble/pkg/seed/software.go new file mode 100644 index 0000000000..ce894a2700 --- /dev/null +++ b/tools/dibble/pkg/seed/software.go @@ -0,0 +1,230 @@ +package seed + +import ( + "embed" + "fmt" + "io/fs" + "path" + "sort" + "strings" +) + +// installerFiles bundles a curated set of installer fixtures into the dibble +// binary so `dibble software custom` can upload real package files without +// the user pointing at a checkout. Most fixtures come from +// server/service/testdata/software-installers/ — the same ones Fleet's own +// tests use. The .msi and .exe entries use upstream-signed installers +// (python-manager, 7-Zip) so we exercise the Windows code paths without +// surfacing the Fleet agent itself as a custom software item. vim.deb is +// excluded for size. +// +//go:embed data/installers/* +var installerFiles embed.FS + +// extensionInstallers lists the curated 2-3 installer fixtures per +// extension. Order matters for display; the first entry per extension is +// uploaded first which keeps log output readable. fleet-osquery.msi is +// intentionally NOT listed: it's the agent installer and must not appear +// as a custom software item. +var extensionInstallers = map[string][]string{ + ".pkg": {"dummy_installer.pkg", "EchoApp.pkg", "no_version.pkg"}, + ".deb": {"emacs.deb", "ruby.deb", "ruby_arm64.deb"}, + ".msi": {"python-manager-26.2.msi"}, + ".exe": {"7z2601.exe", "7z2601-x64.exe", "7z2601-arm64.exe"}, + ".rpm": {"ruby.rpm"}, // only fixture available + ".tar.gz": {"test.tar.gz"}, // only fixture available + ".ipa": {"ipa_test.ipa", "ipa_test2.ipa"}, +} + +// extensionScripts maps an extension to the install / uninstall script form +// field values to send with the upload. Most extensions are left empty so +// Fleet auto-generates the commands. .tar.gz and .exe both reject uploads +// without explicit install scripts ("Install script is required for .X +// packages") so we ship placeholders that satisfy the validator. +var extensionScripts = map[string]struct { + install string + uninstall string +}{ + ".tar.gz": { + install: "#!/bin/sh\necho 'dibble seeded — replace with real install logic'\n", + uninstall: "#!/bin/sh\necho 'dibble seeded — replace with real uninstall logic'\n", + }, + ".exe": { + install: "# dibble seeded — replace with real install logic\n$exitCode = (Start-Process -FilePath $env:INSTALLER_PATH -ArgumentList \"/S\" -PassThru -Wait).ExitCode\nExit $exitCode\n", + uninstall: "# dibble seeded — replace with real uninstall logic\nExit 0\n", + }, +} + +// SoftwareOptions configures the custom-package and Fleet-maintained-app +// seeders. TeamID == 0 targets "no team" (global); a non-zero value scopes +// the upload to that team. +type SoftwareOptions struct { + // TeamID selects the team that uploaded installers and added maintained + // apps land under. Zero means no team / global. + TeamID uint + + // MaintainedAppCount is how many entries from /software/fleet_maintained_apps + // to seed. Zero skips FMA entirely. Defaults to 3 when running the + // "maintained" / "all" subcommands. + MaintainedAppCount int +} + +// loadInstaller reads a single embedded fixture by name. +func loadInstaller(name string) ([]byte, error) { + full := path.Join("data/installers", name) + return fs.ReadFile(installerFiles, full) +} + +// sortedExtensions returns the supported extensions in a deterministic +// order so seeded output is stable across runs. +func sortedExtensions() []string { + keys := make([]string, 0, len(extensionInstallers)) + for k := range extensionInstallers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// SoftwareCustom uploads the curated 2-3 installer fixtures per supported +// extension to the given team (or "no team" when opt.TeamID == 0). Each +// upload posts multipart to /api/latest/fleet/software/package, the same +// endpoint Fleet's UI calls when adding a custom package. +// +// Install / uninstall scripts are left empty; the server auto-generates +// defaults based on the file extension. +func SoftwareCustom(c Client, log Logger, opt SoftwareOptions) Result { + res := Result{Entity: "software (custom)"} + + scope := "no team" + teamField := "" + if opt.TeamID > 0 { + teamField = fmt.Sprintf("%d", opt.TeamID) + scope = fmt.Sprintf("team=%d", opt.TeamID) + } + + for _, ext := range sortedExtensions() { + for _, fixture := range extensionInstallers[ext] { + content, err := loadInstaller(fixture) + if err != nil { + res.Errors = append(res.Errors, + fmt.Errorf("load %s: %w", fixture, err)) + continue + } + // Build the fields map per-fixture: extensions like .tar.gz + // need an explicit install_script, others let Fleet + // auto-generate one. + fields := map[string]string{} + if teamField != "" { + fields["fleet_id"] = teamField + } + if scripts, ok := extensionScripts[ext]; ok { + fields["install_script"] = scripts.install + fields["uninstall_script"] = scripts.uninstall + } + files := []MultipartFile{{ + FieldName: "software", + Filename: fixture, + Content: content, + }} + err = c.PostMultipart("/api/latest/fleet/software/package", fields, files, nil) + switch { + case err == nil: + res.Created++ + log.Printf("software (%s) %s [%s]", scope, fixture, ext) + case IsAlreadyExists(err): + res.Skipped++ + log.Printf("software (%s) %s already exists", scope, fixture) + default: + res.Errors = append(res.Errors, + fmt.Errorf("%s: %w", fixture, err)) + } + } + } + return res +} + +// maintainedApp is the subset of fleet.MaintainedApp the seeder cares +// about. Decoded from the listFleetMaintainedApps response. +type maintainedApp struct { + ID uint `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` +} + +type listMaintainedAppsResponse struct { + FleetMaintainedApps []maintainedApp `json:"fleet_maintained_apps"` +} + +// SoftwareMaintained adds a handful of Fleet-maintained apps to the given +// team by: +// +// 1. GET /api/latest/fleet/software/fleet_maintained_apps to discover what +// the server's catalog contains (the list is generated server-side). +// 2. POST /api/latest/fleet/software/fleet_maintained_apps with each +// selected fleet_maintained_app_id. +// +// Adding FMAs is per-team; opt.TeamID == 0 means no team / global. +func SoftwareMaintained(c Client, log Logger, opt SoftwareOptions) Result { + res := Result{Entity: "software (maintained)"} + if opt.MaintainedAppCount <= 0 { + return res + } + + listPath := "/api/latest/fleet/software/fleet_maintained_apps" + if opt.TeamID > 0 { + listPath = fmt.Sprintf("%s?team_id=%d", listPath, opt.TeamID) + } + var list listMaintainedAppsResponse + if err := c.Get(listPath, &list); err != nil { + res.Errors = append(res.Errors, + fmt.Errorf("list fleet-maintained apps: %w", err)) + return res + } + if len(list.FleetMaintainedApps) == 0 { + log.Printf("software (maintained): server returned no maintained apps to add") + return res + } + + // Pick the first N from the server's list — the catalog is curated so + // the head of the list is stable. + n := opt.MaintainedAppCount + if n > len(list.FleetMaintainedApps) { + n = len(list.FleetMaintainedApps) + } + + for i := 0; i < n; i++ { + app := list.FleetMaintainedApps[i] + body := map[string]any{ + "fleet_maintained_app_id": app.ID, + } + if opt.TeamID > 0 { + body["fleet_id"] = opt.TeamID + } + err := c.Post("/api/latest/fleet/software/fleet_maintained_apps", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("software (maintained) %s [%s] id=%d", + app.Name, app.Platform, app.ID) + case IsAlreadyExists(err) || isAlreadyAdded(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, + fmt.Errorf("add maintained app %s (id=%d): %w", app.Name, app.ID, err)) + } + } + return res +} + +// isAlreadyAdded recognizes the "already added" error Fleet returns when a +// maintained app is re-added to the same team. The error isn't a generic +// "already exists" 409, so we have to sniff the message. +func isAlreadyAdded(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already") && + (strings.Contains(msg, "added") || strings.Contains(msg, "associated")) +} diff --git a/tools/dibble/pkg/seed/teams.go b/tools/dibble/pkg/seed/teams.go new file mode 100644 index 0000000000..4d7a45f821 --- /dev/null +++ b/tools/dibble/pkg/seed/teams.go @@ -0,0 +1,107 @@ +package seed + +import ( + "errors" + "fmt" + + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// Team is the minimal team representation we need to chain seeders. Many +// downstream seeders (policies, profiles, scripts) need the integer ID. +type Team struct { + ID uint + Name string +} + +type teamCreateResp struct { + Team struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"team"` + // The newer API renames "team" → "fleet" in some responses. + Fleet struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"fleet"` +} + +// Teams creates `count` teams (aka fleets) and returns the resulting list. +// Existing teams with the same name are looked up so callers always get an ID. +func Teams(c Client, log Logger, theme themes.Theme, count int) ([]Team, Result) { + res := Result{Entity: "teams"} + teams := make([]Team, 0, count) + for i := 0; i < count; i++ { + name := themes.TeamName(theme, i) + body := map[string]any{"name": name} + var resp teamCreateResp + err := c.Post("/api/latest/fleet/fleets", body, &resp) + id := resp.Team.ID + if id == 0 { + id = resp.Fleet.ID + } + switch { + case err == nil: + res.Created++ + teams = append(teams, Team{ID: id, Name: name}) + log.Printf("team %s (id=%d)", name, id) + case IsAlreadyExists(err): + res.Skipped++ + // Look up the existing team so downstream seeders can scope by + // team id. A genuine lookup failure (network, auth) is a hard + // error; "not found" is silently ignored so a renamed/deleted + // team doesn't block the rest of the run. + got, lookupErr := findTeamByName(c, name) + switch { + case lookupErr == nil: + teams = append(teams, got) + case errors.As(lookupErr, new(errTeamNotFound)): + // Team exists by name conflict but not in the list — odd, + // but treat as a no-op rather than failing the run. + default: + res.Errors = append(res.Errors, + fmt.Errorf("lookup existing team %q: %w", name, lookupErr)) + } + default: + res.Errors = append(res.Errors, err) + } + } + return teams, res +} + +type listTeamsResp struct { + Teams []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"teams"` + Fleets []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"fleets"` +} + +func findTeamByName(c Client, name string) (Team, error) { + var resp listTeamsResp + if err := c.Get("/api/latest/fleet/fleets?per_page=500", &resp); err != nil { + return Team{}, err + } + list := resp.Teams + if len(list) == 0 { + for _, f := range resp.Fleets { + list = append(list, struct { + ID uint `json:"id"` + Name string `json:"name"` + }{ID: f.ID, Name: f.Name}) + } + } + for _, t := range list { + if t.Name == name { + return Team{ID: t.ID, Name: t.Name}, nil + } + } + return Team{}, errTeamNotFound{name: name} +} + +type errTeamNotFound struct{ name string } + +func (e errTeamNotFound) Error() string { return "team not found: " + e.name } diff --git a/tools/dibble/pkg/seed/templates/profile.mobileconfig.tmpl b/tools/dibble/pkg/seed/templates/profile.mobileconfig.tmpl new file mode 100644 index 0000000000..b42dec57e3 --- /dev/null +++ b/tools/dibble/pkg/seed/templates/profile.mobileconfig.tmpl @@ -0,0 +1,18 @@ + + + + + PayloadContent + + PayloadDisplayName + {{NAME}} + PayloadIdentifier + {{IDENT}} + PayloadType + Configuration + PayloadUUID + {{UUID}} + PayloadVersion + 1 + + diff --git a/tools/dibble/pkg/seed/templates/profile.windows.xml.tmpl b/tools/dibble/pkg/seed/templates/profile.windows.xml.tmpl new file mode 100644 index 0000000000..c49b546693 --- /dev/null +++ b/tools/dibble/pkg/seed/templates/profile.windows.xml.tmpl @@ -0,0 +1,7 @@ + + + {{LOCURI}} + chr + dibble-{{NAME}} + + diff --git a/tools/dibble/pkg/seed/users.go b/tools/dibble/pkg/seed/users.go new file mode 100644 index 0000000000..d9f279831c --- /dev/null +++ b/tools/dibble/pkg/seed/users.go @@ -0,0 +1,50 @@ +package seed + +import ( + "github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes" +) + +// SeedUsers creates `count` users on the Fleet server using names drawn from +// the given theme. Roles cycle through observer / observer_plus / maintainer / +// admin / gitops so the seeded set covers every permission level. +// +// All users share a known dev password so tests can sign in as them; production +// Fleets should never run this against a real deployment. +const SeededUserPassword = "DibbleSeed123!" + +var seededRoles = []string{ + "observer", "observer_plus", "maintainer", "admin", "gitops", +} + +func Users(c Client, log Logger, theme themes.Theme, count int) Result { + res := Result{Entity: "users"} + for i := 0; i < count; i++ { + name := themes.FullName(theme, i) + email := themes.Email(theme, i) + role := seededRoles[i%len(seededRoles)] + body := map[string]any{ + "name": name, + "email": email, + "global_role": role, + "admin_forced_password_reset": false, + "password": SeededUserPassword, + } + // GitOps users authenticate via API token only, but Fleet's + // /users/admin endpoint still requires a password be set on the + // record (only SSO-enabled creates waive that requirement). + if role == "gitops" { + body["api_only"] = true + } + err := c.Post("/api/latest/fleet/users/admin", body, nil) + switch { + case err == nil: + res.Created++ + log.Printf("user %s <%s> [%s]", name, email, role) + case IsAlreadyExists(err): + res.Skipped++ + default: + res.Errors = append(res.Errors, err) + } + } + return res +} diff --git a/tools/dibble/pkg/seed/vulns.go b/tools/dibble/pkg/seed/vulns.go new file mode 100644 index 0000000000..e322c9d0ad --- /dev/null +++ b/tools/dibble/pkg/seed/vulns.go @@ -0,0 +1,214 @@ +package seed + +import ( + "context" + "crypto/md5" //nolint:gosec // matches fleet.Software.ComputeRawChecksum + "database/sql" + "embed" + "encoding/csv" + "errors" + "fmt" + "strings" + + "github.com/go-sql-driver/mysql" +) + +//go:embed data/software-macos.csv data/software-ubuntu.csv data/software-win.csv +var vulnCSVs embed.FS + +// VulnsOptions configures the vuln seeder. Counts are per-platform; pass 0 +// to skip a platform. DSN is a MySQL connection string. +type VulnsOptions struct { + DSN string + MacOS int + Ubuntu int + Windows int + BatchSiz int +} + +// Vulns writes plausible-looking software rows directly to MySQL so the +// background vulnerability scanner has inventory to chew on. Each row gets +// a fleet-compatible checksum and is inserted with INSERT IGNORE, so +// re-runs are idempotent against the unique software-checksum index. +// +// Scope (intentional): this seeder only writes the `software` table. It +// does NOT create hosts, `host_software` rows, or `software_cpe` +// associations — the legacy tools/software/vulnerabilities/seed_data tool +// did all of those, but dibble keeps the surface minimal and leaves +// host/CPE wiring to the real ingest path (or a future, opt-in flag). +// Vulnerabilities themselves are derived by Fleet's vuln scanner after +// CPE matching, so an empty `software_cpe` means no CVEs will surface +// from these rows on their own. +func Vulns(ctx context.Context, log Logger, opt VulnsOptions) Result { + res := Result{Entity: "vulns"} + if opt.BatchSiz <= 0 { + opt.BatchSiz = 500 + } + + dsn, err := mysqlDSN(opt.DSN, true) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err)) + return res + } + db, err := sql.Open("mysql", dsn) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err)) + return res + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err)) + return res + } + + plans := []struct { + platform string + file string + count int + }{ + {"darwin", "data/software-macos.csv", opt.MacOS}, + {"ubuntu", "data/software-ubuntu.csv", opt.Ubuntu}, + {"windows", "data/software-win.csv", opt.Windows}, + } + + for _, p := range plans { + if p.count <= 0 { + continue + } + rows, err := readCSV(p.file) + if err != nil { + res.Errors = append(res.Errors, fmt.Errorf("read %s: %w", p.file, err)) + continue + } + if err := insertSoftware(ctx, db, p.platform, rows, p.count, opt.BatchSiz); err != nil { + res.Errors = append(res.Errors, fmt.Errorf("insert %s: %w", p.platform, err)) + continue + } + log.Printf("vulns: %d %s rows inserted from %s", p.count, p.platform, p.file) + res.Created += p.count + } + return res +} + +// mysqlDSN parses dsn with the MySQL driver, enables ParseTime and +// (optionally) MultiStatements, and returns the re-formatted DSN. Building +// the DSN this way instead of `dsn + "?parseTime=true"` preserves any +// query params the caller already set (e.g. tls=true, charset=utf8mb4). +func mysqlDSN(dsn string, multiStatements bool) (string, error) { + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + return "", err + } + cfg.ParseTime = true + if multiStatements { + cfg.MultiStatements = true + } + return cfg.FormatDSN(), nil +} + +// readCSV returns the data rows of an embedded CSV, with the header row +// stripped. Returns an error if the file has no data rows. +func readCSV(name string) ([][]string, error) { + f, err := vulnCSVs.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + all, err := csv.NewReader(f).ReadAll() + if err != nil { + return nil, err + } + if len(all) <= 1 { + return nil, errors.New("csv has no data rows after header") + } + return all[1:], nil +} + +// softwareChecksum mirrors fleet.Software.ComputeRawChecksum so that rows +// inserted here satisfy the unique idx_software_checksum index. The order +// of fields here MUST match server/fleet/software.go. +func softwareChecksum(name, version, source, bundleID, release, arch, vendor, extensionFor, extensionID string) []byte { + h := md5.New() //nolint:gosec // DB lookup optimization, not security + cols := []string{version, source, bundleID, release, arch, vendor, extensionFor, extensionID, name} + _, _ = fmt.Fprint(h, strings.Join(cols, "\x00")) + return h.Sum(nil) +} + +// insertSoftware appends `count` rows to the software table. Each row gets +// a checksum that matches fleet.Software.ComputeRawChecksum so the unique +// idx_software_checksum index is satisfied. Idempotency here is +// intentionally weak — INSERT IGNORE will drop duplicate-checksum rows on +// re-runs, which matches the legacy tool's expectations. +// +// The platform argument is unused by the INSERT itself — source values in +// the CSVs (e.g. "apps", "deb_packages", "programs") already encode the +// platform. It's kept on the signature so the caller can log it. +func insertSoftware(ctx context.Context, db *sql.DB, _platform string, rows [][]string, count, batch int) error { + if len(rows) == 0 { + return errors.New("empty csv") + } + // SET FOREIGN_KEY_CHECKS=0 is a session variable. Pin everything below + // to a single connection so the disable, the inserts, and the restore + // all hit the same MySQL session — otherwise the pool can hand the + // FK-disabled connection to an unrelated caller. + conn, err := db.Conn(ctx) + if err != nil { + return fmt.Errorf("acquire dedicated conn: %w", err) + } + defer conn.Close() + + if _, err := conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil { + return err + } + defer func() { + // Use a fresh context so the restore still runs even if ctx was + // cancelled mid-insert. + _, _ = conn.ExecContext(context.Background(), "SET FOREIGN_KEY_CHECKS=1") + }() + + for i := 0; i < count; i += batch { + end := i + batch + if end > count { + end = count + } + size := end - i + placeholders := make([]string, 0, size) + args := make([]any, 0, size*6) + for k := 0; k < size; k++ { + row := rows[(i+k)%len(rows)] + if len(row) < 3 { + continue + } + // CSV columns: name, version, source, bundle_identifier, + // release, vendor_old, arch, vendor. Older rows may be + // short; fall back to empty strings for missing fields. + name, version, source := row[0], row[1], row[2] + bundleID := csvField(row, 3) + release := csvField(row, 4) + arch := csvField(row, 6) + vendor := csvField(row, 7) + sum := softwareChecksum(name, version, source, bundleID, release, arch, vendor, "", "") + placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?)") + args = append(args, + name, version, source, bundleID, release, arch, vendor, "", sum, + ) + } + if len(placeholders) == 0 { + continue + } + stmt := "INSERT IGNORE INTO software " + + "(name, version, source, bundle_identifier, `release`, arch, vendor, extension_for, checksum) " + + "VALUES " + strings.Join(placeholders, ",") + if _, err := conn.ExecContext(ctx, stmt, args...); err != nil { + return err + } + } + return nil +} + +func csvField(row []string, i int) string { + if i < len(row) { + return row[i] + } + return "" +} diff --git a/tools/dibble/pkg/themes/cosmere.go b/tools/dibble/pkg/themes/cosmere.go new file mode 100644 index 0000000000..3e5b2d9f0e --- /dev/null +++ b/tools/dibble/pkg/themes/cosmere.go @@ -0,0 +1,44 @@ +package themes + +func init() { + Register(Theme{ + Name: "cosmere", + Display: "Brandon Sanderson's Cosmere", + Domain: "cosmere.test", + Users: []Person{ + {"Kaladin", "Stormblessed", "kaladin"}, + {"Shallan", "Davar", "shallan"}, + {"Dalinar", "Kholin", "dalinar"}, + {"Vin", "", "vin"}, + {"Kelsier", "", "kelsier"}, + {"Sazed", "", "sazed"}, + {"Wax", "Ladrian", "wax"}, + {"Wayne", "", "wayne"}, + {"Hoid", "", "hoid"}, + {"Lift", "", "lift"}, + }, + Teams: []string{ + "Bridge Four", "Survivors of the Final Empire", "Knights Radiant", + "Worldsingers", "Ghostbloods", "Skybreakers", "Mistborn", + }, + Policies: []Named{ + {"Stormlight reserve check", "Spheres dun → noncompliant"}, + {"Journey before destination", "Process maturity policy"}, + {"No metal in cabinets", "Allomantic safety"}, + {"Honor the oaths", "Audit log integrity"}, + }, + Software: []Named{ + {"Stormlight Manager", "Bind to highstorms only"}, + {"Allomantic Burn Console", "Eight basic metals"}, + {"Shardplate Diagnostics", "Pre-flight checks"}, + }, + Labels: []string{ + "windrunner-eligible", "mistborn-active", + "radiant-aspirant", "ghostblood-affiliated", + }, + Scripts: []Named{ + {"swear-the-oaths.sh", "Idempotent — second-ideal-aware"}, + {"burn-pewter.ps1", "Windows variant for Allomancers"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/dbz.go b/tools/dibble/pkg/themes/dbz.go new file mode 100644 index 0000000000..783b82570e --- /dev/null +++ b/tools/dibble/pkg/themes/dbz.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "dbz", + Display: "Dragon Ball Z", + Domain: "capsulecorp.test", + Users: []Person{ + {"Son", "Goku", "goku"}, + {"Vegeta", "", "vegeta"}, + {"Piccolo", "", "piccolo"}, + {"Son", "Gohan", "gohan"}, + {"Krillin", "", "krillin"}, + {"Bulma", "Briefs", "bulma"}, + {"Trunks", "", "trunks"}, + {"Tien", "Shinhan", "tien"}, + }, + Teams: []string{ + "Z Fighters", "Capsule Corp", "Frieza Force", + "Ginyu Force", "Cell Saga Survivors", "Saiyan Royal Family", + }, + Policies: []Named{ + {"Power level > 9000", "Scouter reading threshold"}, + {"Senzu bean inventory", "At least one bean on file"}, + {"No Cell installations", "Block bio-android packages"}, + {"Kamehameha rate limit", "Per-host energy quota"}, + }, + Software: []Named{ + {"Scouter Firmware", "Don't trust readings over 9000"}, + {"Dragon Radar", "Locates all seven balls"}, + {"Hyperbolic Time Chamber", "1-day-per-year scheduler"}, + }, + Labels: []string{ + "super-saiyan", "namekian", + "earthling", "androids-allowed", + }, + Scripts: []Named{ + {"charge-kamehameha.sh", "Five-second windup"}, + {"open-capsule.ps1", "Vehicle deployment"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/ghibli.go b/tools/dibble/pkg/themes/ghibli.go new file mode 100644 index 0000000000..004cb6f021 --- /dev/null +++ b/tools/dibble/pkg/themes/ghibli.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "ghibli", + Display: "Studio Ghibli", + Domain: "spiritedaway.test", + Users: []Person{ + {"Totoro", "", "totoro"}, + {"Chihiro", "Ogino", "chihiro"}, + {"Howl", "Jenkins", "howl"}, + {"Sophie", "Hatter", "sophie"}, + {"Kiki", "", "kiki"}, + {"San", "", "san"}, + {"Ashitaka", "", "ashitaka"}, + {"Ponyo", "", "ponyo"}, + {"Calcifer", "", "calcifer"}, + }, + Teams: []string{ + "Spirited Bathhouse", "Moving Castle", "Iron Town", + "Witch's Delivery Service", "Laputa Sky Castle", "Forest Spirits", + }, + Policies: []Named{ + {"No name-stealing", "Yubaba contract guard"}, + {"Catbus availability", "On-demand transit ready"}, + {"Soot-sprite hygiene", "Konpeito stock check"}, + }, + Software: []Named{ + {"Catbus Transit", "12 legs, very punctual"}, + {"Calcifer Heater", "Don't move out of the hearth"}, + {"Howl's Door Compass", "Four destinations, color-coded"}, + }, + Labels: []string{ + "soot-sprite-detected", "name-not-stolen", + "forest-spirit-blessed", "ponyo-friendly", + }, + Scripts: []Named{ + {"summon-catbus.sh", "Wait 12 seconds at the stop"}, + {"feed-calcifer.sh", "Use bacon and eggs"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/goodplace.go b/tools/dibble/pkg/themes/goodplace.go new file mode 100644 index 0000000000..1f353e459d --- /dev/null +++ b/tools/dibble/pkg/themes/goodplace.go @@ -0,0 +1,41 @@ +package themes + +func init() { + Register(Theme{ + Name: "goodplace", + Display: "The Good Place", + Domain: "thegoodplace.test", + Users: []Person{ + {"Eleanor", "Shellstrop", "eleanor"}, + {"Chidi", "Anagonye", "chidi"}, + {"Tahani", "Al-Jamil", "tahani"}, + {"Jason", "Mendoza", "jason"}, + {"Michael", "Demon", "michael"}, + {"Janet", "", "janet"}, + {"Doug", "Forcett", "doug"}, + }, + Teams: []string{ + "The Good Place", "The Bad Place", "The Medium Place", + "Mindy St. Claire's Cabin", "Judge Gen's Chambers", + }, + Policies: []Named{ + {"Ethical compliance check", "Has the host done a good deed today?"}, + {"No frozen yogurt detected", "Hard pass on frozen yogurt installs"}, + {"Trolley problem readiness", "Chidi-grade decision-making"}, + {"Forking filter enabled", "All ' fork ' substring detection"}, + }, + Software: []Named{ + {"Janet Void Browser", "Returns anything you ask for"}, + {"Tahani's Charity Tracker", "Name-dropping included"}, + {"Jason's Molotov Manual", "Use with discretion"}, + }, + Labels: []string{ + "good-place-resident", "bad-place-architect", + "truly-good", "trolley-survivor", + }, + Scripts: []Named{ + {"compute-ethical-score.sh", "Sum of moral acts since boot"}, + {"summon-janet.sh", "Snap your fingers"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/hitchhikers.go b/tools/dibble/pkg/themes/hitchhikers.go new file mode 100644 index 0000000000..074deb1d80 --- /dev/null +++ b/tools/dibble/pkg/themes/hitchhikers.go @@ -0,0 +1,45 @@ +package themes + +func init() { + Register(Theme{ + Name: "hitchhikers", + Display: "Hitchhiker's Guide to the Galaxy", + Domain: "magrathea.test", + Users: []Person{ + {"Arthur", "Dent", "arthur"}, + {"Ford", "Prefect", "ford"}, + {"Zaphod", "Beeblebrox", "zaphod"}, + {"Trillian", "McMillan", "trillian"}, + {"Marvin", "Android", "marvin"}, + {"Slartibartfast", "", "slarti"}, + {"Fenchurch", "", "fenchurch"}, + {"Agrajag", "", "agrajag"}, + }, + Teams: []string{ + "Heart of Gold", "Magrathea", "Vogon Constructor Fleet", + "Restaurant at the End of the Universe", "Mostly Harmless", + }, + Policies: []Named{ + {"Towel readiness check", "Verifies the host has a towel attached"}, + {"Don't Panic banner", "Asserts the desktop wallpaper says DON'T PANIC"}, + {"Babel fish installed", "Required for galactic-language support"}, + {"Improbability drive disabled in prod", "No accidental whale-summoning"}, + {"Answer-to-life check", "Result must equal 42"}, + }, + Software: []Named{ + {"Babel Fish", "Universal translator"}, + {"Pan Galactic Gargle Blaster", "Do not deploy on Fridays"}, + {"Sub-Etha Sens-O-Matic", "Catch interstellar transit"}, + {"Eddie the Shipboard Computer", "Cheerful and very annoying"}, + }, + Labels: []string{ + "knows-where-towel-is", "vogon-poetry-resistant", + "improbability-tolerant", "froody-cats", + }, + Scripts: []Named{ + {"summon-towel.sh", "Ensures a towel is in $HOME"}, + {"play-vogon-poetry.sh", "For interrogation purposes only"}, + {"engage-improbability.ps1", "Windows variant. Use sparingly."}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/lotr.go b/tools/dibble/pkg/themes/lotr.go new file mode 100644 index 0000000000..b79bc3acd3 --- /dev/null +++ b/tools/dibble/pkg/themes/lotr.go @@ -0,0 +1,43 @@ +package themes + +func init() { + Register(Theme{ + Name: "lotr", + Display: "The Lord of the Rings", + Domain: "middleearth.test", + Users: []Person{ + {"Frodo", "Baggins", "frodo"}, + {"Samwise", "Gamgee", "sam"}, + {"Meriadoc", "Brandybuck", "merry"}, + {"Peregrin", "Took", "pippin"}, + {"Gandalf", "the Grey", "gandalf"}, + {"Aragorn", "Elessar", "aragorn"}, + {"Legolas", "Greenleaf", "legolas"}, + {"Gimli", "son of Glóin", "gimli"}, + {"Boromir", "of Gondor", "boromir"}, + }, + Teams: []string{ + "The Fellowship", "Rivendell", "Rohan", "Gondor", + "Lothlórien", "Erebor", "Mordor", + }, + Policies: []Named{ + {"No One Ring in registry", "Inventory must not contain the One Ring"}, + {"Second breakfast permitted", "Hobbit dietary compliance"}, + {"Path of Caradhras blocked", "Use Moria pass instead"}, + {"You shall not pass", "Balrog firewall rule"}, + }, + Software: []Named{ + {"Palantír Viewer", "Use with extreme caution"}, + {"Sting Sensor", "Glows blue near orcs"}, + {"Mithril Patch Manager", "Light yet strong"}, + }, + Labels: []string{ + "ring-bearer", "took-took", + "speaks-elvish", "knows-the-old-songs", + }, + Scripts: []Named{ + {"cast-into-mount-doom.sh", "Final disposal procedure"}, + {"summon-eagles.ps1", "Late but effective"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/mascot.go b/tools/dibble/pkg/themes/mascot.go new file mode 100644 index 0000000000..d96899cea0 --- /dev/null +++ b/tools/dibble/pkg/themes/mascot.go @@ -0,0 +1,25 @@ +package themes + +// TapirSnout is the inline bullet character for progress lines. +const TapirSnout = ">·)~" + +// tapirArt is the Braille-block tapir used in both the wizard banner and +// the README header. Small and Large are the same art today; kept as +// distinct exported names so callers can diverge later without churn. +const tapirArt = ` +⠀⠀⠀⠀⠀⣀⣀⣤⣤⣤⣤⣤⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⡆⠸⣿⣿⣿⣷⣶⣤⣄⣾⣷⡄⠀⠀⠀⠀⠀⠀ +⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⡀⠀⠀⠀ +⠀⣤⣤⣤⣈⡉⠛⢿⣿⣿⣿⣿⣿⡆⢸⣿⣿⣿⣿⣿⣿⣿⣿⣧⣽⣿⣷⣄⠀⠀ +⠀⢿⠿⣿⣿⣿⣷⣤⡈⢻⣿⣿⣿⣇⠈⣿⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣿⣿⡄⠀ +⠀⠈⠀⢸⣿⣿⣿⣿⠇⠀⠛⠛⠛⠋⠀⢻⣿⣿⡟⢉⠀⠀⠈⠙⠛⠿⠏⣿⣷⠀ +⠀⠀⢠⣿⣿⡿⠟⢁⡄⠀⠀⠀⠀⠀⠀⠈⣿⣿⡇⣾⡀⠀⠀⠀⠀⠀⠀⠸⠿⠀ +⠀⠀⠸⣿⣿⠀⢸⣿⣇⠀⠀⠀⠀⠀⠀⠀⢹⣿⡇⠸⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠙⠛⠃⠀⠛⠛⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠀⠙⠃⠀⠀⠀⠀⠀⠀⠀⠀ +` + +// TapirSmall is the wizard banner art. +const TapirSmall = tapirArt + +// TapirLarge is the README header art. +const TapirLarge = tapirArt diff --git a/tools/dibble/pkg/themes/parksrec.go b/tools/dibble/pkg/themes/parksrec.go new file mode 100644 index 0000000000..e5a93a6946 --- /dev/null +++ b/tools/dibble/pkg/themes/parksrec.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "parksrec", + Display: "Parks and Recreation", + Domain: "pawneeparks.test", + Users: []Person{ + {"Leslie", "Knope", "leslie"}, + {"Ron", "Swanson", "ron"}, + {"Tom", "Haverford", "tom"}, + {"April", "Ludgate", "april"}, + {"Andy", "Dwyer", "andy"}, + {"Ben", "Wyatt", "ben"}, + {"Donna", "Meagle", "donna"}, + {"Jerry", "Gergich", "jerry"}, + }, + Teams: []string{ + "Pawnee Parks Department", "Eagleton", "Snakehole Lounge Staff", + "Entertainment 720", "Mouse Rat", "Pawnee Goddesses", + }, + Policies: []Named{ + {"Breakfast food only check", "Bacon, eggs, and waffles required"}, + {"No Eagleton software", "Block all rival-town origin packages"}, + {"Treat Yo Self readiness", "Once a year only"}, + {"Ron's privacy posture", "No tracking software allowed"}, + }, + Software: []Named{ + {"Pawnee Government Portal", "More efficient than Eagleton's"}, + {"Rent-a-Swag", "Tom's first business"}, + {"DJ Roomba", "Just keeps roaming"}, + }, + Labels: []string{ + "loves-breakfast", "hates-eagleton", + "mouse-rat-fan", "literally-treat-yo-self", + }, + Scripts: []Named{ + {"build-pit-park.sh", "Lot 48 mobilization"}, + {"deploy-li_l-sebastian.sh", "5000 candles in the wind"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/robin_williams.go b/tools/dibble/pkg/themes/robin_williams.go new file mode 100644 index 0000000000..d08b07bbc3 --- /dev/null +++ b/tools/dibble/pkg/themes/robin_williams.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "robin_williams", + Display: "Robin Williams characters", + Domain: "naunau.test", + Users: []Person{ + {"Genie", "of the Lamp", "genie"}, + {"Mrs.", "Doubtfire", "doubtfire"}, + {"John", "Keating", "keating"}, + {"Sean", "Maguire", "sean"}, + {"Peter", "Banning", "peterpan"}, + {"Alan", "Parrish", "alan"}, + {"Patch", "Adams", "patch"}, + {"Adrian", "Cronauer", "adrian"}, + {"Mork", "of Ork", "mork"}, + }, + Teams: []string{ + "Cave of Wonders", "Welton Academy", "Jumanji Survivors", + "Hillside Hospital", "Saigon Radio AFRS", "Mount Hope", + }, + Policies: []Named{ + {"Carpe Diem reminder", "Seize the day, daily"}, + {"No phenomenal cosmic power without itty-bitty living space", "Privilege bounds check"}, + {"Nanu nanu greeting required", "Mork-compliant SSH banner"}, + }, + Software: []Named{ + {"Lamp Polish Pro", "Three wishes maximum"}, + {"Jumanji Board Sim", "Do not run on weekends"}, + {"Patch.MD Clinic Suite", "Laughter is the best metric"}, + }, + Labels: []string{ + "oh-captain-my-captain", "second-star-to-the-right", + "good-morning-vietnam", "shazbot", + }, + Scripts: []Named{ + {"rub-the-lamp.sh", "Yields one (1) wish"}, + {"goooooood-morning.sh", "Radio broadcast bootstrap"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/sailor_moon.go b/tools/dibble/pkg/themes/sailor_moon.go new file mode 100644 index 0000000000..562d6c52bb --- /dev/null +++ b/tools/dibble/pkg/themes/sailor_moon.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "sailor_moon", + Display: "Sailor Moon", + Domain: "moonkingdom.test", + Users: []Person{ + {"Usagi", "Tsukino", "usagi"}, + {"Ami", "Mizuno", "ami"}, + {"Rei", "Hino", "rei"}, + {"Makoto", "Kino", "makoto"}, + {"Minako", "Aino", "minako"}, + {"Mamoru", "Chiba", "mamoru"}, + {"Chibiusa", "", "chibiusa"}, + {"Luna", "", "luna"}, + {"Artemis", "", "artemis"}, + }, + Teams: []string{ + "Inner Senshi", "Outer Senshi", "Dark Kingdom", + "Black Moon Clan", "Death Busters", "Moon Kingdom", + }, + Policies: []Named{ + {"Moon Tiara armed", "Frisbee-ready"}, + {"No youma in inventory", "Block demonic processes"}, + {"In the name of the Moon", "Banner-text compliance"}, + }, + Software: []Named{ + {"Moon Tiara Action", "Throw-and-recall enabled"}, + {"Disguise Pen", "Costume changes per minute"}, + {"Luna-P Chat", "Talking-cat console"}, + }, + Labels: []string{ + "crystal-tokyo-ready", "moon-prism-active", + "talking-cat-detected", "tuxedo-mask-fan", + }, + Scripts: []Named{ + {"transform.sh", "Idempotent magical girl transformation"}, + {"summon-tuxedo-mask.ps1", "Convenient last-second arrival"}, + }, + }) +} diff --git a/tools/dibble/pkg/themes/theme.go b/tools/dibble/pkg/themes/theme.go new file mode 100644 index 0000000000..cd42e98916 --- /dev/null +++ b/tools/dibble/pkg/themes/theme.go @@ -0,0 +1,201 @@ +// Package themes contains the curated character-name datasets that dibble +// uses to make seeded data fun to look at in the Fleet UI. +// +// Each theme is a Theme literal. New themes go in their own file alongside +// the existing ones and register themselves with init() calling Register. +// +// Pick is deterministic given a (theme, kind, index) tuple — so the same +// seed produces the same data. +package themes + +import ( + "fmt" + "sort" + "strings" +) + +// Person is one character from a theme. +type Person struct { + First, Last, Handle string +} + +// Named is a generic name+description pair used for policies, software, scripts, etc. +type Named struct { + Name, Desc string +} + +// Theme is a curated set of character references for one piece of media. +type Theme struct { + Name string + Display string // human-friendly title + Domain string // email domain suffix + Users []Person + Teams []string + Policies []Named + Software []Named + Labels []string + Scripts []Named + + // Suffix, when non-empty, is appended to every generated name so that + // re-running dibble against an already-seeded Fleet produces fresh + // entries instead of "already exists" skips. + Suffix string +} + +var registry = map[string]Theme{} + +// Register adds a theme to the registry. Called from each theme's init(). +func Register(t Theme) { + if t.Name == "" { + panic("themes: empty Name") + } + registry[t.Name] = t +} + +// All returns every registered theme, sorted by Name. +func All() []Theme { + out := make([]Theme, 0, len(registry)) + for _, t := range registry { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Names returns every registered theme name plus "mix", sorted. +func Names() []string { + out := []string{"mix"} + for n := range registry { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Get returns the named theme, or the mix theme for "mix"/"". +func Get(name string) (Theme, error) { + if name == "" || name == "mix" { + return Mix(), nil + } + t, ok := registry[name] + if !ok { + return Theme{}, fmt.Errorf("unknown theme %q (known: %s)", name, strings.Join(Names(), ", ")) + } + return t, nil +} + +// Mix interleaves every registered theme into one combined Theme. Useful +// when you want maximum chaos in a single seed run. +func Mix() Theme { + mix := Theme{Name: "mix", Display: "Mix", Domain: "dibble.dev"} + for _, t := range All() { + mix.Users = append(mix.Users, t.Users...) + mix.Teams = append(mix.Teams, t.Teams...) + mix.Policies = append(mix.Policies, t.Policies...) + mix.Software = append(mix.Software, t.Software...) + mix.Labels = append(mix.Labels, t.Labels...) + mix.Scripts = append(mix.Scripts, t.Scripts...) + } + return mix +} + +// Email returns a themed email address for the i-th user, wrapping around. +func Email(t Theme, i int) string { + if len(t.Users) == 0 { + return fmt.Sprintf("user%d@%s", i, t.domain()) + } + p := t.Users[i%len(t.Users)] + handle := p.Handle + if handle == "" { + handle = strings.ToLower(p.First) + } + // Append the index past the first wrap so we never collide. + if i >= len(t.Users) { + handle = fmt.Sprintf("%s%d", handle, i/len(t.Users)) + } + if t.Suffix != "" { + // Drop the suffix into the local part so emails stay valid. + handle = handle + "+" + emailSafe(t.Suffix) + } + return fmt.Sprintf("%s@%s", handle, t.domain()) +} + +// FullName returns the i-th user's display name. +func FullName(t Theme, i int) string { + if len(t.Users) == 0 { + return fmt.Sprintf("User %d", i) + } + p := t.Users[i%len(t.Users)] + name := strings.TrimSpace(p.First + " " + p.Last) + if i >= len(t.Users) { + name = fmt.Sprintf("%s %d", name, i/len(t.Users)) + } + return appendSuffix(name, t.Suffix) +} + +// TeamName returns the i-th team name, wrapping with a numeric suffix to +// avoid duplicate-name conflicts on the Fleet side. +func TeamName(t Theme, i int) string { + if len(t.Teams) == 0 { + return fmt.Sprintf("Team %d", i+1) + } + name := t.Teams[i%len(t.Teams)] + if i >= len(t.Teams) { + name = fmt.Sprintf("%s %d", name, i/len(t.Teams)+1) + } + return appendSuffix(name, t.Suffix) +} + +// Pick returns the i-th item of the named slice. kind is one of: +// "policy", "software", "label", "script". Wraps around with a numeric suffix. +func Pick(t Theme, kind string, i int) Named { + var pool []Named + switch kind { + case "policy": + pool = t.Policies + case "software": + pool = t.Software + case "script": + pool = t.Scripts + case "label": + labels := make([]Named, len(t.Labels)) + for k, l := range t.Labels { + labels[k] = Named{Name: l, Desc: ""} + } + pool = labels + default: + return Named{Name: fmt.Sprintf("item-%d", i)} + } + if len(pool) == 0 { + return Named{Name: fmt.Sprintf("%s-%d", kind, i+1)} + } + n := pool[i%len(pool)] + if i >= len(pool) { + n.Name = fmt.Sprintf("%s %d", n.Name, i/len(pool)+1) + } + n.Name = appendSuffix(n.Name, t.Suffix) + return n +} + +// appendSuffix tacks " (suffix)" onto a name when a suffix is set. The +// parens-and-space form reads well in the Fleet UI: "Heart of Gold (b3f1)". +func appendSuffix(name, suffix string) string { + if suffix == "" { + return name + } + return name + " (" + suffix + ")" +} + +// emailSafe lower-cases and strips characters that would otherwise produce +// an invalid email local-part. +func emailSafe(s string) string { + r := strings.NewReplacer(" ", "-", "(", "", ")", "", "/", "-", "\\", "-") + return strings.ToLower(r.Replace(s)) +} + +func (t Theme) domain() string { + if t.Domain != "" { + return t.Domain + } + return "dibble.dev" +} diff --git a/tools/dibble/pkg/themes/theme_test.go b/tools/dibble/pkg/themes/theme_test.go new file mode 100644 index 0000000000..2cd4fb27e3 --- /dev/null +++ b/tools/dibble/pkg/themes/theme_test.go @@ -0,0 +1,60 @@ +package themes + +import ( + "strings" + "testing" +) + +func TestAllThemesRegistered(t *testing.T) { + want := []string{ + "hitchhikers", "goodplace", "parksrec", "tng", "lotr", + "dbz", "robin_williams", "ghibli", "cosmere", "sailor_moon", + } + for _, name := range want { + if _, err := Get(name); err != nil { + t.Errorf("theme %q not registered: %v", name, err) + } + } +} + +func TestMixIsNonEmpty(t *testing.T) { + m := Mix() + if len(m.Users) == 0 || len(m.Teams) == 0 || len(m.Policies) == 0 { + t.Fatalf("mix theme came back empty: %+v", m) + } +} + +func TestEmailWrapsWithoutCollision(t *testing.T) { + th, err := Get("hitchhikers") + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + n := len(th.Users) * 3 + for i := 0; i < n; i++ { + e := Email(th, i) + if seen[e] { + t.Errorf("duplicate email at i=%d: %s", i, e) + } + seen[e] = true + if !strings.Contains(e, "@") { + t.Errorf("not an email: %s", e) + } + } +} + +func TestPickReturnsKindAwareDefault(t *testing.T) { + th, _ := Get("hitchhikers") + if got := Pick(th, "policy", 0); got.Name == "" { + t.Fatalf("Pick policy returned empty name") + } + if got := Pick(th, "nonsense-kind", 0); !strings.HasPrefix(got.Name, "item-") { + t.Errorf("expected fallback name for unknown kind, got %q", got.Name) + } +} + +func TestGetUnknownErrors(t *testing.T) { + if _, err := Get("notreal"); err == nil { + t.Error("expected error for unknown theme") + } +} diff --git a/tools/dibble/pkg/themes/tng.go b/tools/dibble/pkg/themes/tng.go new file mode 100644 index 0000000000..921bf69f98 --- /dev/null +++ b/tools/dibble/pkg/themes/tng.go @@ -0,0 +1,42 @@ +package themes + +func init() { + Register(Theme{ + Name: "tng", + Display: "Star Trek: The Next Generation", + Domain: "ussenterprise.test", + Users: []Person{ + {"Jean-Luc", "Picard", "picard"}, + {"William", "Riker", "riker"}, + {"Data", "", "data"}, + {"Worf", "", "worf"}, + {"Geordi", "La Forge", "geordi"}, + {"Deanna", "Troi", "troi"}, + {"Beverly", "Crusher", "crusher"}, + {"Wesley", "Crusher", "wesley"}, + }, + Teams: []string{ + "USS Enterprise NCC-1701-D", "Klingon Empire", "Borg Collective", + "Romulan Star Empire", "Q Continuum", "Ten Forward Staff", + }, + Policies: []Named{ + {"Earl Grey availability", "Replicator must offer hot tea"}, + {"Prime Directive compliance", "Non-interference posture"}, + {"Shields up readiness", "Default to raised when scanning"}, + {"Make-it-so cadence", "Daily directive execution"}, + }, + Software: []Named{ + {"Holodeck Programs", "Caution: program 9 caused incidents"}, + {"LCARS Console", "Library Computer Access/Retrieval System"}, + {"Tricorder firmware", "v1701.D"}, + }, + Labels: []string{ + "engage-ready", "tea-earl-grey-hot", + "resistance-is-futile", "klingon-honorable", + }, + Scripts: []Named{ + {"raise-shields.sh", "Defensive posture"}, + {"engage-warp.sh", "Set course, warp 9"}, + }, + }) +} diff --git a/tools/loadtest/fleetd_labels/README.md b/tools/loadtest/fleetd_labels/README.md index 59fe40a3cf..564eb2f6ef 100644 --- a/tools/loadtest/fleetd_labels/README.md +++ b/tools/loadtest/fleetd_labels/README.md @@ -1,3 +1,10 @@ +> [!NOTE] +> **Prefer [`dibble`](../../dibble/README.md) for seeding labels.** The equivalent is: +> ```bash +> ./tools/dibble/dibble labels --count N +> ``` +> This tool is kept for backwards compatibility. We'll remove it once nothing references it. + # fleetd_labels This tool can be used to set up a fixed set of manual labels to the hosts in a Fleet deployment. diff --git a/tools/loadtest/scripts_and_profiles/README.md b/tools/loadtest/scripts_and_profiles/README.md new file mode 100644 index 0000000000..eb6889fdf6 --- /dev/null +++ b/tools/loadtest/scripts_and_profiles/README.md @@ -0,0 +1,27 @@ +> [!NOTE] +> **Prefer [`dibble`](../../dibble/README.md) for seeding scripts and MDM profiles.** The equivalents are: +> ```bash +> ./tools/dibble/dibble scripts --count N +> ./tools/dibble/dibble profiles --count N +> ``` +> This tool is kept for backwards compatibility. We'll remove it once nothing references it. + +# scripts_and_profiles + +Go program used to load-test scripts and MDM profiles for the +[unified queue story](https://github.com/fleetdm/fleet/issues/22866). + +It creates `team_count` teams and attaches a fixed set of scripts and MDM +profiles (Apple + Windows) to each, exercising the scripts/profile distribution +pipeline at scale. + +## Usage + +```bash +go run ./tools/loadtest/scripts_and_profiles \ + -fleet_url https://localhost:8080 \ + -api_token "$FLEET_API_TOKEN" \ + -team_count 20 +``` + +Add `-cleanup_teams` to delete the created teams after the run. diff --git a/tools/loadtest/unified_queue/README.md b/tools/loadtest/unified_queue/README.md index ea4955c373..0b69bc87e6 100644 --- a/tools/loadtest/unified_queue/README.md +++ b/tools/loadtest/unified_queue/README.md @@ -1,3 +1,13 @@ +> [!NOTE] +> **Prefer [`dibble`](../../dibble/README.md) for seeding the software/script +> entities this loadtest needs.** Use: +> ```bash +> ./tools/dibble/dibble software # seeds software titles +> ./tools/dibble/dibble scripts # seeds saved scripts +> ``` +> The actual install/enqueue-for-an-hour loadtest logic is unique to this tool; +> we'll consolidate when dibble's software upload path is finished. + # Load testing of the unified queue story This is the Go program used to run load tests for the [unified queue story](https://github.com/fleetdm/fleet/issues/22866). diff --git a/tools/mdm/apple/loadtest/README.md b/tools/mdm/apple/loadtest/README.md new file mode 100644 index 0000000000..e8683ef88d --- /dev/null +++ b/tools/mdm/apple/loadtest/README.md @@ -0,0 +1,27 @@ +> [!NOTE] +> **Prefer [`dibble`](../../../dibble/README.md) for seeding teams + Apple MDM +> profiles.** The equivalents are: +> ```bash +> ./tools/dibble/dibble teams --count N +> ./tools/dibble/dibble profiles --count N # Apple .mobileconfig + Windows .xml +> ``` +> This tool is kept for backwards compatibility — it focuses on Apple MDM +> enrollment load and uses Apple-specific assets dibble doesn't. We'll +> consolidate when dibble's MDM enrollment path is finished. + +# Apple MDM load test + +Loadtest harness for Apple MDM enrollment. Creates teams, attaches enrollment +profiles, and simulates enrollment churn. + +## Usage + +```bash +go run ./tools/mdm/apple/loadtest \ + -fleet_url https://localhost:8080 \ + -api_token "$FLEET_API_TOKEN" \ + -team_count 50 \ + -loop_count 1 +``` + +See `loadtest.go` for the full set of flags. diff --git a/tools/seed_data/queries/README.md b/tools/seed_data/queries/README.md new file mode 100644 index 0000000000..091add7c86 --- /dev/null +++ b/tools/seed_data/queries/README.md @@ -0,0 +1,21 @@ +> [!NOTE] +> **Prefer [`dibble`](../../dibble/README.md) for seeding reports (formerly "queries").** The equivalent is: +> ```bash +> ./tools/dibble/dibble reports --count N +> ``` +> This script is kept for backwards compatibility. It writes directly to +> MySQL (1M rows by default) and is useful for stress-testing the reports +> table specifically; dibble uses the API path. + +# Bulk query/report seeder + +Direct-MySQL loader that inserts ~1M rows into the `queries` table. Used for +stress-testing scenarios where the API-driven path would be too slow. + +## Usage + +Assumes the local dev `docker-compose` MySQL (`fleet:insecure@localhost:3306/fleet`). + +```bash +go run ./tools/seed_data/queries/seed_queries.go +``` diff --git a/tools/software/vulnerabilities/README.md b/tools/software/vulnerabilities/README.md index 89e7395230..40c6ba6d11 100644 --- a/tools/software/vulnerabilities/README.md +++ b/tools/software/vulnerabilities/README.md @@ -1,3 +1,11 @@ +> [!NOTE] +> **Prefer [`dibble`](../../dibble/README.md) for seeding vulnerable software.** The equivalent is: +> ```bash +> ./tools/dibble/dibble vulns --macos 100 --ubuntu 100 --windows 100 +> ``` +> dibble embeds the same CSVs and writes to MySQL directly, just like this tool. +> This script is kept for backwards compatibility. + # Vulnerability Data Seeder The purpose of `seed_vuln_data.go` is to provide developers an easy way to insert hosts and software diff --git a/tools/software/vulnerabilities/performance_test/seeder/README.md b/tools/software/vulnerabilities/performance_test/seeder/README.md new file mode 100644 index 0000000000..ac073fca7e --- /dev/null +++ b/tools/software/vulnerabilities/performance_test/seeder/README.md @@ -0,0 +1,23 @@ +> [!NOTE] +> **Prefer [`dibble`](../../../../dibble/README.md) for seeding vulnerable +> software at volume.** The equivalent is: +> ```bash +> ./tools/dibble/dibble vulns --macos 100000 --ubuntu 100000 --windows 100000 +> ``` +> This script is kept for backwards compatibility. It's a higher-volume +> sibling of `tools/software/vulnerabilities/seed_data/seed_vuln_data.go` +> with deadlock-retry logic specific to vuln performance testing. + +# Vulnerability performance-test seeder + +Bulk MySQL seeder used by the vulnerability performance test harness +(`tools/software/vulnerabilities/performance_test/tester/`). Writes very large +numbers of software rows directly to MySQL with deadlock retries. + +## Usage + +```bash +go run ./tools/software/vulnerabilities/performance_test/seeder +``` + +See `volume_vuln_seeder.go` for configuration constants. diff --git a/tools/team-builder/README.md b/tools/team-builder/README.md index 4106af574b..005fe71baa 100644 --- a/tools/team-builder/README.md +++ b/tools/team-builder/README.md @@ -1,3 +1,10 @@ +> [!NOTE] +> **Prefer [`dibble`](../dibble/README.md) for seeding teams.** The equivalent is: +> ```bash +> ./tools/dibble/dibble teams --count N +> ``` +> This tool is kept for backwards compatibility (it also generates installer +> packages, which dibble does not). We'll remove it once no one references it. # Fleet fleet builder