Compare commits

..

10 commits

Author SHA256 Message Date
984a37e0cb chore: silence bascule-shell config dead-code warnings
Some checks failed
CI / dco (push) Has been cancelled
CI / build (push) Has been cancelled
The TOML schema for ~/.config/bascule/shell.toml carries
`servers = [{alias, hostname, port}]` entries that
bascule-shell deserializes but doesn't read at runtime. The
shell-side server chooser uses ssh host aliases (dev.gsh /
stg.gsh / prod.gsh) instead.

Marking the fields `#[allow(dead_code)]` with a comment
preserves the TOML wire format (so users with existing config
files don't get a parse error) without leaving the compiler
warning.

Verification:
  $ cargo build --workspace
    | grep -c "warning:"
  0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-08 13:49:32 -04:00
33f6bf729a feat(hfl): bascule-core SAT compose routes through HFL when available
Post-M6 enhancement: when /dev/substrate-hfl is loaded and the
bascule-core `hfl` feature is enabled, the new
compose_via_hfl_or_local() entry point hands a serialized
SessionClaim to the kernel's attestation::SAT_BUNDLE function and
uses the kernel-composed SatBundle (proto-encoded) in place of the
locally-composed M1 bundle. Kernel composition has access to TPM
state, governance state, and platform-claim producers Bascule can't
reach from userspace.

Without the `hfl` feature: M1 path unchanged.
With the `hfl` feature but no kernel module: graceful fallback to
the M1 local compose path. Per ADR D9, the SAT chain stays alive
regardless of HFL availability.

bascule-core::hfl_sat (NEW, behind --features hfl):
  - compose_via_hfl_or_local(inputs) tries the kernel path first.
    On any failure (device missing, ioctl error, decode error)
    it logs at debug and returns the local M1 compose result.
  - try_compose_via_hfl() encodes the SessionClaim with prost,
    dispatches via HflClient::dispatch(0x0005, 1, claim_bytes,
    [0u8;32], current_epoch), and decodes the result as a
    proto SatBundle.
  - 2 unit tests cover the device-absent fallback path (+ structure
    equivalence with the M1 local compose).

Cargo.toml:
  - Workspace deps: hfl-types + substrate-hfl as path deps to the
    substrate workspace (cross-workspace, CI mounts both checkouts
    side by side).
  - bascule-core gains a `hfl` feature gating hfl-types +
    substrate-hfl + prost (the last for SessionClaim::encode_to_vec
    on the substrate-proto-generated types).

Tested (Docker rust:1.88-bookworm):
  cargo build  -p bascule-core                       clean
  cargo test   -p bascule-core --lib sat              7/7  (M1 regression)
  cargo build  -p bascule-core --features hfl        clean
  cargo test   -p bascule-core --features hfl --lib  26/26
    +2 hfl_sat tests on top of the existing bascule-core suite

Branched off main (post-merge of the M1..M5 stack).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-08 10:41:09 -04:00
2520525ec6 feat(m5): bascule-shell prefers SPIFFE SVID URI as principal
Adds bascule_shell::identity::detect_spiffe_svid which reads a
SPIFFE SVID URI from /var/run/spire/svid-uri (override via
SPIFFE_SVID_PATH). When present it wins over Entra/AZ-CLI/
Kerberos/cached-OIDC/system-user, becoming the SAT session_leaf
actor field that QM's M5 SpiffeSvidEvaluator validates against
the cluster allowlist.

Why a file read instead of the SPIFFE Workload API: bascule-shell
ships independently from QM and the standard SPIRE k8s sidecar
writes the URI as /var/run/spire/svid-uri alongside svid.pem.
The file path is hermetic for tests and matches the deploy model.
If a future iteration needs continuous SVID URI rotation, switch
to a notify watcher or pull spiffe::workload_api.

Trust domain is parsed and surfaced as Identity.domain so the
banner / dashboard can show "spiffe://gh.dev" affiliation.

bascule_shell::main::set_env: auth_method == "spiffe" maps to
BASCULE_ROLES = "operator" by default. SPIRE-attested workloads
are explicitly cluster-issued so they get operator role until
per-workload provisioning lands. The existing precedence
(caller-set BASCULE_ROLES wins) is unchanged.

Bascule mTLS *channel* construction (Bascule -> QM gRPC
renegotiation) is intentionally NOT wired in this commit.
Per ADR D9 hot path is local; the renegotiation client is
deferred to M6 alongside the Rekor signing client because they
share the rustls dep tree.

Tested (Docker rust:1.88-bookworm):
  cargo build  -p bascule-shell -p bascule-core   clean
  cargo test   -p bascule-core --lib sat          7/7 (M1 regression)

Stacked on feat/m3-defcon-env.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-07 21:05:05 -04:00
df5a2a6f88 feat(m3): bascule-shell exports DEFCON env vars from posture-current.json
bascule-shell loads /opt/substrate/posture/current.json
(BASCULE_POSTURE_FILE override) at session start and exports:

  BASCULE_DEFCON_LEVEL          numeric global level (1..5)
  BASCULE_POSTURE_LEVEL         alias (already shipped in M1)
  BASCULE_CAPABILITY_CEILING    CAP_NONE..CAP_GOVERN
  BASCULE_CEREMONY_REQUIRED     "0" / "1"
  BASCULE_MAX_SESSION_TTL       minutes, omitted when 0

Fail-soft: missing/malformed file degrades to peacetime defaults so
the shell exec path stays alive on misconfigured hosts.

The new posture.rs module is a tiny inline snapshot loader (60 LOC,
serde_json on top of an already-present dep) — bascule-oss does not
pull libgsh as a dep, so the JSON wire format produced by
substrate-operator is the contract. gsh and bascule-shell share that
contract, not Rust types.

Stacked on feat/m2-roles-export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-07 19:02:34 -04:00
56529626f6 feat(m2): bascule-shell exports BASCULE_ROLES for gsh's role check
bascule-shell::set_env now populates BASCULE_ROLES so gsh's
M2 role-aware classifier has something to match against.

Precedence:
  1. Caller-set BASCULE_ROLES wins (env var preserved as-is).
  2. Otherwise derive a default from auth_method:
       oidc-entra | oidc-cached | kerberos -> operator
       ssh-key                              -> apprentice
       _                                    -> apprentice

The auth-method fallback is intentionally minimal — bascule-oss
Identity has no real roles field, and proper role provisioning
(Entra group claims, SPIFFE workload roles) lands in M5. This
default at least populates the env var so M2's role-deny path
is exercised end-to-end on existing dev shells instead of
silently empty.

Stacked on feat/m1-session-sat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-07 17:52:50 -04:00
999c78ef4c feat(m1): bascule-shell composes a real SAT anchored on session_leaf
Replaces the opaque BASCULE_ATTESTATION_HASH (a SHA over a
"pcrN:val;ima:hash;" evidence string) with a real proto-canonical
SatBundle composed from the operator's identity + local platform
attestation, anchored on the L4 SessionClaim.

bascule-core::sat (NEW): pure composer module.
- build_session_claim(SessionInputs) -> SessionClaim builds the
  L4 leaf from {principal, auth_method, actor_type,
  identity_verified, platform_attested, software_verified,
  nonce_seed}, computes posture per SAT-SPEC-0002 §7, and
  populates the L1/L2/L3 binding fields with zero-padded
  placeholders until upstream producers exist.
- compose_local(SessionClaim) -> ComposedSat assembles the proto
  SatBundle via SatBundleBuilder. Hot path stays local per ADR D9
  (zero network); QM's gRPC ComposeSat is the warm-path surface.
- 7 unit tests cover layer/actor wiring, posture math at each
  evidence level, deterministic nonce, sat_hash uniqueness across
  principal changes.

bascule-shell: composes the SAT in main() right before execvp
of the inner shell — that's the OSS equivalent of an "Authenticated
-> ShellActive" transition (the OSS Bascule has no russh state
machine; it's a CLI wrapper). Exports the new env var surface:

  BASCULE_SAT_HASH            hex of proto sat_hash (canonical)
  BASCULE_SESSION_CLAIM_HASH  hex of L4 leaf hash
  BASCULE_SESSION_ID          UUID from SessionClaim
  BASCULE_POSTURE_LEVEL       SAT-SPEC-0002 §7 posture

  BASCULE_ATTESTATION_HASH    retained as compat alias (gsh /
                              dashboard consumers); now points at
                              the proto sat_hash, not the old
                              evidence-string SHA.

Cross-workspace path dep: substrate-proto via
../substrate-project/substrate/crates/substrate-proto. CI mounts
~/projects as one volume so the path resolves. Switching to a git
dep is post-MVP.

Note: russh-keys pulls `home` which requires Rust 1.88; CI bumps
the docker image accordingly. No code change.

Tested:
  cargo build -p bascule-core -p bascule-shell             clean
  cargo test  -p bascule-core --lib sat                    7/7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
2026-04-07 14:38:20 -04:00
6eb2de5dc0 docs: update all documentation for management API + dashboard
Updated 9 files to reflect:
  Management API (axum, port 9090) — embedded in bascule-server
  Dioxus dashboard components (WASM web target)
  6 crates in workspace (was 4)

README.md:
  Added Management API + Dashboard features section
  Added dashboard row to comparison table

docs/architecture.md:
  Updated diagram showing dual-listener architecture
  Added Management API section explaining Arc<SessionStore> sharing
  Updated crate table (6 crates)

docs/configuration.md:
  Added [dashboard] config section reference

docs/observability.md:
  Added Management API monitoring section with curl examples

docs/quickstart.md:
  Added Management API quick start section

docs/comparison.md:
  Added dashboard and TPM attestation rows

CLAUDE.md + CONTRIBUTING.md:
  Updated crate lists and feature flags

config/bascule.example.toml:
  Added [dashboard] section

All 17 README links verified valid. Build clean.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-05 17:17:18 -04:00
72fa8cee92 feat: embedded management API (axum, port 9090)
Same binary, same process, two listeners:
  Port 2222: SSH proxy (russh)
  Port 9090: Management API (axum)

API endpoints:
  GET /api/sessions         — active sessions
  GET /api/sessions/history — recent history (last 500)
  GET /api/stats            — aggregate analytics
  GET /api/health           — server health + version
  GET /api/info             — server capabilities

Session tracking:
  Arc<SessionStore> shared between SSH handler and API
  In-memory: active sessions + 500-session history ring buffer
  Tracks: auth breakdown, peak concurrent, TPM attested %

Feature flag:
  --features dashboard (default on) — includes axum + tower-http
  --no-default-features — SSH-only, no HTTP dependency

Config:
  [dashboard] section: enabled, listen address

All smoke tests pass. 0 substrate deps.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-05 15:09:26 -04:00
04dd74d15f feat: Dioxus dashboard — session analytics + WASM web target
New crates:
  bascule-dashboard — shared Dioxus component library
    SessionTable: live active sessions with auth/backend/TPM status
    StatsCards: active count, 24h total, TPM attested %, failed auth
    StatusBar: connection health indicator
    types.rs: DashboardSession, DashboardStats, HealthResponse

  bascule-dashboard-web — WASM web target (Dioxus 0.6 + web features)
    Compiles to wasm32-unknown-unknown
    Dark-first CSS (light mode via prefers-color-scheme)
    Monospace data display, clean stat cards

  bascule-core/store.rs — in-memory session store
    SessionStore with active sessions + aggregate stats
    Updated via SessionHandler hooks

Both dashboard library and web WASM target compile clean.
Server and shell builds unaffected. Zero substrate deps.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-05 14:10:01 -04:00
4aa7e9d816 docs: DCO, NOTICE, and governance framework
DCO (Developer Certificate of Origin):
  Standard DCO 1.1 (Linux kernel, CNCF, Kubernetes standard)
  Contributors retain copyright — no rights assignment

NOTICE:
  Copyright attribution (Guildhouse LLC)
  Contributors retain copyright, own their implementations
  SessionHandler/AuthProvider as public API boundary
  Tribal jurisdiction for voluntary dispute resolution

GOVERNANCE.md:
  Project governance model and decision making
  IP framework: Guildhouse brand vs contributor code vs shared Apache 2.0
  SessionHandler trait IS the product boundary
  Tribal dispute resolution: voluntary, technically informed
  Tribal partnership mission

CI:
  DCO sign-off check on pull requests
  Existing commits on main exempt

README + CONTRIBUTING:
  Governance section, DCO instructions, corporate guidance

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-05 11:13:20 -04:00
39 changed files with 2956 additions and 51 deletions

View file

@ -11,6 +11,35 @@ env:
RUSTFLAGS: -Dwarnings
jobs:
dco:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: DCO Sign-Off Check
run: |
COMMITS=$(git log --format='%H %s' origin/main..HEAD 2>/dev/null || echo "")
if [ -z "$COMMITS" ]; then
echo "No new commits to check"
exit 0
fi
FAILED=0
while IFS= read -r line; do
HASH=$(echo "$line" | cut -d' ' -f1)
MSG=$(git log -1 --format='%B' "$HASH")
if ! echo "$MSG" | grep -q "Signed-off-by:"; then
echo "Missing DCO sign-off: $line"
FAILED=1
fi
done <<< "$COMMITS"
if [ "$FAILED" -eq 1 ]; then
echo "All commits must include Signed-off-by. Use: git commit -s"
exit 1
fi
echo "All commits have DCO sign-off"
build:
runs-on: ubuntu-latest
steps:

View file

@ -6,10 +6,12 @@ Bascule is an identity-aware SSH proxy. It authenticates operators via SSH keys
## Workspace
- `crates/bascule-core/` — Library: SSH server, auth, session backends, hooks
- `crates/bascule-server/` — Binary: CLI wrapper, config loading, telemetry setup
- `crates/bascule-core/` — Library: SSH server, auth, session backends, hooks, session store
- `crates/bascule-server/` — Binary: SSH proxy + embedded management API (axum)
- `crates/bascule-auth-agent-id/` — Optional: Entra Agent ID auth provider
- `crates/bascule-shell/` — Binary: Identity-aware login shell with TPM attestation
- `crates/bascule-dashboard/` — Library: Dioxus UI components
- `crates/bascule-dashboard-web/` — Binary: WASM web dashboard target
- `charts/bascule/` — Helm chart for K8s deployment
- `images/` — Curated container images for operator environments
@ -30,6 +32,7 @@ make dev # Run locally in dev mode
## Feature flags (bascule-server)
- `dashboard` — Management API on port 9090 (default on)
- `agent-id` — Entra Agent ID auth
## Rules

View file

@ -15,10 +15,12 @@ Bascule is a Rust workspace:
| Crate | Purpose |
|-------|---------|
| `bascule-core` | Library — SSH server, auth, PTY, proxy, container, hooks |
| `bascule-server` | Binary — CLI, config, telemetry |
| `bascule-core` | Library — SSH server, auth, PTY, proxy, container, hooks, store |
| `bascule-server` | Binary — SSH proxy + management API (axum) |
| `bascule-auth-agent-id` | Optional — Entra Agent ID auth |
| `bascule-shell` | Binary — Identity-aware login shell |
| `bascule-shell` | Binary — Identity-aware login shell with TPM |
| `bascule-dashboard` | Library — Dioxus UI components |
| `bascule-dashboard-web` | Binary — WASM web dashboard target |
## Testing
@ -45,6 +47,38 @@ cargo test --all
Format: `type: description`
Types: feat, fix, docs, chore, refactor, test
## Developer Certificate of Origin (DCO)
All contributions must be signed off under the
[Developer Certificate of Origin](DCO) (DCO Version 1.1).
Every commit must include a `Signed-off-by` line:
```bash
git commit -s -m "feat: my contribution"
# Result includes: Signed-off-by: Your Name <your.email@example.com>
```
### What DCO means
- You certify you have the right to submit the contribution
- Your contribution is licensed under Apache 2.0
- You **retain copyright** to your contribution
- You do NOT assign ownership to Guildhouse
### Corporate contributions
If contributing on behalf of your employer, ensure your employer
permits the contribution under Apache 2.0.
### Why DCO (not CLA)
- DCO doesn't assign rights — you keep your copyright
- DCO is lightweight — one line per commit, no legal review
- DCO is standard — used by Linux, Kubernetes, CNCF projects
See [GOVERNANCE.md](GOVERNANCE.md) for the full IP and dispute resolution framework.
## License
By contributing, you agree your contributions are licensed under Apache 2.0.

1393
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,12 @@
[workspace]
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id", "crates/bascule-shell"]
members = [
"crates/bascule-core",
"crates/bascule-server",
"crates/bascule-auth-agent-id",
"crates/bascule-shell",
"crates/bascule-dashboard",
"crates/bascule-dashboard-web",
]
resolver = "2"
[workspace.package]
@ -8,6 +15,18 @@ edition = "2021"
license = "Apache-2.0"
[workspace.dependencies]
# M1: depend on the canonical proto-generated SAT types from the substrate
# workspace. Path dep across workspaces; CI mounts both checkouts side by
# side. substrate-proto is itself a thin facade over substrate-common which
# owns the tonic-build invocation, so consumers only need to worry about
# the substrate-proto import surface.
substrate-proto = { path = "../substrate-project/substrate/crates/substrate-proto" }
# Post-M6: optional HFL kernel-dispatch path. Pulled by `bascule-core`
# under the `hfl` feature so the M5/M1 SAT compose path can route
# through audit::SAT_BUNDLE on nodes with /dev/substrate-hfl loaded.
# Cross-workspace path dep — CI mounts both checkouts side by side.
hfl-types = { path = "../substrate-project/substrate/crates/hfl-types" }
substrate-hfl = { path = "../substrate-project/substrate/crates/substrate-hfl" }
russh = "0.46"
russh-keys = "0.46"
tokio = { version = "1", features = ["full"] }

34
DCO Normal file
View file

@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project and the open source license(s) involved.

95
GOVERNANCE.md Normal file
View file

@ -0,0 +1,95 @@
# Bascule Project Governance
## Maintainers
Bascule is maintained by [Guildhouse LLC](https://guildhouse.dev).
**Lead maintainer:** Tyler King
## Decision Making
Technical decisions are made by the maintainers with input from the
community via GitHub Issues and Pull Requests.
Major architectural decisions (new backends, new auth providers, trait
changes) are discussed in Issues before implementation.
## Contributions
Contributions are accepted under the [Developer Certificate of Origin](DCO)
(DCO). All commits must include a `Signed-off-by` line:
```bash
git commit -s -m "feat: my contribution"
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
## Intellectual Property
### What Guildhouse owns
- The Bascule name, logo, and brand
- The proprietary governance stack (GSAP protocol, SAT attestation,
HFL host functions, Chronicle audit, DEFCON posture system)
- These components are NOT part of bascule-oss and are maintained
in separate repositories under separate licenses
### What contributors own
- Copyright to their own contributions (DCO does NOT assign copyright)
- Any implementation of the `SessionHandler` or `AuthProvider` traits
- Any product, service, or extension built using bascule-core as a library
### What's shared (Apache 2.0)
- All code in this repository
- The `SessionHandler` and `AuthProvider` trait definitions
- The SSH proxy core, session backends, and authentication framework
- Documentation, Helm charts, container images, and build scripts
### The boundary
The `SessionHandler` trait is the product boundary. Everything below
the trait (in this repo) is Apache 2.0. Implementations of the trait
are the intellectual property of their authors.
Guildhouse's own session handler (which adds authorization contexts,
completion receipts, operational posture, and audit trails) is
proprietary. It depends on bascule-core as a library, which Apache
2.0 permits.
Third parties are encouraged to build their own session handlers:
- **Security vendors**: integrate risk scoring into session policy
- **Compliance teams**: add audit logging for regulatory requirements
- **Platform teams**: enforce organization-specific access policies
- **MSPs**: build multi-tenant session management
## Dispute Resolution
Guildhouse partners with tribal sovereign nations to provide
technically informed dispute resolution for open source projects.
Disputes may be submitted to tribal jurisdiction for resolution by
adjudicators with expertise in open source software, contribution
attribution, and digital governance.
This forum is:
- **Voluntary** — contributors may choose any court of competent jurisdiction
- **Technically informed** — adjudicators understand open source licensing
- **Efficient** — designed for faster resolution than federal litigation
- **Sovereignty-respecting** — rooted in tribal self-determination
This does not limit any rights under the Apache 2.0 license.
## Tribal Partnership
Guildhouse's mission includes advancing cybersecurity capacity and
digital sovereignty in Indian Country through:
- **Mentorship**: training tribal members in cloud-native infrastructure
- **Infrastructure**: deploying systems on tribal-controlled hardware
- **Jurisdiction**: developing legal frameworks for digital governance
- **Economic participation**: connecting tribal technologists with the
cloud consulting ecosystem

59
NOTICE Normal file
View file

@ -0,0 +1,59 @@
Bascule — Identity-Aware SSH Proxy
Copyright 2026 Guildhouse LLC
This product includes software developed by the Bascule contributors
under the Developer Certificate of Origin (DCO) Version 1.1.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
CONTRIBUTION AND GOVERNANCE NOTICE
Bascule is maintained by Guildhouse LLC. Contributions are accepted
under the Developer Certificate of Origin (see DCO file).
Contributors retain copyright to their contributions. By contributing
under the DCO, you grant a license consistent with Apache 2.0 — you
do NOT assign ownership of your contribution to Guildhouse.
Contributors are free to:
- Use their contributions in any other project
- Build proprietary products using the SessionHandler or AuthProvider
traits as extension points
- Create and maintain their own session handler implementations
- Commercialize their extensions independently
The SessionHandler and AuthProvider traits are public APIs. Any
implementation of these traits is the intellectual property of its
author, not of Guildhouse LLC. This applies equally to Guildhouse's
own implementations and to third-party implementations.
---
DISPUTE RESOLUTION
Guildhouse LLC operates in partnership with tribal sovereign nations
to advance digital governance infrastructure and cybersecurity
capacity in Indian Country.
Disputes arising from contributions to this project, including but
not limited to attribution, contribution scope, and agreement
interpretation, may be submitted to tribal jurisdiction for
resolution. This forum is offered as an efficient, technically
informed alternative and does not limit any rights granted under the
Apache 2.0 license.
Participation in tribal dispute resolution is voluntary. Contributors
may alternatively pursue resolution through any court of competent
jurisdiction.

View file

@ -41,10 +41,19 @@ See [docs/quickstart.md](docs/quickstart.md) for Docker, Helm, and container mod
- Read-only rootfs option
- NetworkPolicy for Kubernetes deployments
### Management API + Dashboard
Built-in HTTP management API (port 9090, `--features dashboard`):
- `GET /api/sessions` — active sessions with auth/backend/TPM status
- `GET /api/stats` — aggregate analytics (auth breakdown, peak concurrent, TPM %)
- `GET /api/health` — server health and version
- WASM dashboard at `/dashboard/` *(coming soon)*
### Observability
- Structured JSON logging (`BASCULE_LOG_FORMAT=json`)
- Tracing spans on auth, session lifecycle, exec requests
- Management API for real-time session monitoring
## Client: bascule-shell
@ -76,6 +85,7 @@ See [docs/bascule-shell.md](docs/bascule-shell.md).
| Container sessions | Yes | No | No |
| AI Agent Identity | Yes (Entra Agent ID) | No | No |
| Binary size | ~7MB | ~150MB | ~100MB |
| Built-in dashboard | Yes (port 9090) | Yes | No |
See [docs/comparison.md](docs/comparison.md).
@ -105,6 +115,16 @@ impl SessionHandler for AuditHandler {
See [docs/architecture.md](docs/architecture.md).
## Governance
Bascule is maintained by [Guildhouse LLC](https://guildhouse.dev).
Contributions are accepted under the [DCO](DCO) — you retain
copyright to your contributions.
The `SessionHandler` and `AuthProvider` traits are public APIs.
Implementations are the intellectual property of their authors.
See [GOVERNANCE.md](GOVERNANCE.md).
## Roadmap
Not yet implemented:

View file

@ -79,6 +79,12 @@ mode = "accept-all"
# shell_container = "shell"
# shell = "/bin/bash"
# ─── Dashboard / Management API ─────────────────────────
# Enabled by default with --features dashboard
[dashboard]
enabled = true
listen = "0.0.0.0:9090"
# ─── Telemetry ──────────────────────────────────────────
# [telemetry]
# otlp_endpoint = "http://localhost:4317"

View file

@ -23,6 +23,24 @@ chrono = { workspace = true }
uuid = { workspace = true }
rand = { workspace = true }
portable-pty = { workspace = true }
substrate-proto = { workspace = true }
sha2 = "0.10"
hex = "0.4"
# Post-M6 optional HFL dispatch path.
hfl-types = { workspace = true, optional = true }
substrate-hfl = { workspace = true, optional = true }
# Needed only with `hfl` for prost::Message::encode_to_vec / decode
# on substrate-proto's generated SatBundle types.
prost = { version = "0.13", optional = true }
[features]
default = []
# Post-M6: route SAT composition through the HFL kernel module's
# attestation::SAT_BUNDLE call when /dev/substrate-hfl is present.
# Without this feature, bascule-core composes SATs locally (M1 path,
# unchanged). With this feature, the local path becomes the fallback
# and the kernel path is preferred when reachable.
hfl = ["dep:hfl-types", "dep:substrate-hfl", "dep:prost"]
[dev-dependencies]
tempfile = "3"

View file

@ -51,6 +51,9 @@ pub struct BasculeConfig {
/// Prometheus metrics.
#[serde(default)]
pub metrics: MetricsConfig,
/// Dashboard / management API.
pub dashboard: Option<DashboardConfig>,
}
#[derive(Debug, Deserialize, Clone)]
@ -125,6 +128,7 @@ impl Default for BasculeConfig {
k8s: None,
telemetry: TelemetryConfig::default(),
metrics: MetricsConfig::default(),
dashboard: None,
}
}
}
@ -350,6 +354,19 @@ fn default_metrics_port() -> u16 {
9090
}
/// Dashboard / management API configuration.
#[derive(Debug, Deserialize, Clone)]
pub struct DashboardConfig {
/// Enable the management API.
#[serde(default = "default_true")]
pub enabled: bool,
/// Listen address.
#[serde(default = "default_dashboard_listen")]
pub listen: String,
}
fn default_dashboard_listen() -> String { "0.0.0.0:9090".to_string() }
fn default_runtime() -> String {
"auto".to_string()
}

View file

@ -0,0 +1,127 @@
//! HFL-routed SAT composition (post-M6, behind the `hfl` feature).
//!
//! M1 ships [`crate::sat::compose_from_inputs`] which composes a
//! [`SatBundle`] locally — fast, hot-path-friendly, but can only
//! populate the L4 SessionClaim because Bascule has no access to
//! the TPM, the kernel governance state, or the platform-claim
//! producer.
//!
//! Post-M6: when the HFL kernel module is loaded
//! (`/dev/substrate-hfl`), Bascule can hand a serialized SessionClaim
//! to the kernel via `attestation::SAT_BUNDLE` (HFL namespace 0x0005,
//! function 1). The kernel composes a richer bundle with whatever
//! L1/L2/L3 layers it has access to, signs the result with its
//! TPM-bound identity, and returns the proto-encoded bytes. Bascule
//! decodes them and uses them in place of the locally-composed bundle.
//!
//! This module is feature-gated (`hfl`). The default build uses the
//! M1 path unchanged.
//!
//! ## Failure handling
//!
//! Every step is fallible (device missing, ioctl error, decode error).
//! [`compose_via_hfl_or_local`] always falls back to the local path
//! on any failure — losing the kernel's richer composition is a
//! degradation, not a session-blocking error. Per ADR D9 the hot
//! path stays alive.
use crate::sat::{compose_local, build_session_claim, ComposedSat, SessionInputs};
use prost::Message;
use substrate_hfl::HflClient;
use substrate_proto::v2::SatBundle;
/// Try to compose a SAT via the HFL kernel path; fall back to the
/// local M1 composition if the kernel is unreachable, the dispatch
/// errors, or the response can't be decoded as a `SatBundle`.
///
/// Always returns a valid `ComposedSat` — the SAT chain stays alive
/// regardless of HFL availability.
pub fn compose_via_hfl_or_local(inputs: &SessionInputs<'_>) -> ComposedSat {
match try_compose_via_hfl(inputs) {
Ok(Some(sat)) => sat,
Ok(None) => compose_local(build_session_claim(inputs)),
Err(e) => {
tracing::debug!(error = %e, "HFL SAT compose failed; falling back to local");
compose_local(build_session_claim(inputs))
}
}
}
fn try_compose_via_hfl(
inputs: &SessionInputs<'_>,
) -> Result<Option<ComposedSat>, Box<dyn std::error::Error>> {
if !std::path::Path::new(substrate_hfl::ioctl::DEVICE_PATH).exists() {
return Ok(None);
}
let client = HflClient::open()?;
let session_claim = build_session_claim(inputs);
let session_claim_bytes = session_claim.encode_to_vec();
let grant_hash = [0u8; 32];
let governance_epoch = client.status().map(|s| s.current_epoch).unwrap_or(0);
let result = client.dispatch(
hfl_types::Namespace::Attestation as u16,
hfl_types::ids::attestation::SAT_BUNDLE,
&session_claim_bytes,
grant_hash,
governance_epoch,
)?;
if result.status != 0 {
return Err(format!("HFL SAT_BUNDLE returned non-zero status: {}", result.status).into());
}
let bundle = SatBundle::decode(result.result.as_slice())?;
let sat_hash_hex = hex::encode(&bundle.sat_hash);
let session_claim_hash_hex = bundle
.session_claim
.as_ref()
.map(|c| hex::encode(&c.claim_hash))
.unwrap_or_default();
Ok(Some(ComposedSat {
bundle,
sat_hash_hex,
session_claim_hash_hex,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn inputs() -> SessionInputs<'static> {
SessionInputs {
principal: "tyler@guildhouse.dev",
auth_method: "spiffe",
actor_type: "human",
identity_verified: true,
platform_attested: false,
software_verified: false,
nonce_seed: Some("hfl-test-seed"),
}
}
#[test]
fn fallback_when_device_absent() {
// CI runners don't have /dev/substrate-hfl. compose_via_hfl_or_local
// MUST return a valid ComposedSat from the local fallback path
// without panicking.
let composed = compose_via_hfl_or_local(&inputs());
assert_eq!(composed.bundle.sat_version, 2);
assert!(composed.bundle.session_claim.is_some());
assert_eq!(composed.sat_hash_hex.len(), 64);
assert_eq!(composed.session_claim_hash_hex.len(), 64);
}
#[test]
fn fallback_matches_local_compose_byte_for_byte() {
// With deterministic nonce_seed, the fallback path should
// produce the same SAT hash as compose_from_inputs would.
let i = inputs();
let via_hfl = compose_via_hfl_or_local(&i);
let local = crate::sat::compose_from_inputs(&i);
// sat_hash includes session_id (UUID) which is random per
// call, so the hashes differ. We assert structure instead.
assert_eq!(via_hfl.bundle.sat_version, local.bundle.sat_version);
assert!(via_hfl.bundle.session_claim.is_some());
assert!(local.bundle.session_claim.is_some());
}
}

View file

@ -16,5 +16,9 @@ pub mod handler;
pub mod hooks;
pub mod proxy;
pub mod pty;
pub mod sat;
#[cfg(feature = "hfl")]
pub mod hfl_sat;
pub mod server;
pub mod session;
pub mod store;

View file

@ -0,0 +1,259 @@
//! SAT composition — turn a Bascule session's identity + platform attestation
//! into a real proto-canonical `SatBundle` anchored on the L4 SessionClaim.
//!
//! M1 milestone: replaces the opaque `BASCULE_ATTESTATION_HASH` composite
//! with a real four-layer SAT (only L4 today; L1/L2/L3 join as the upstream
//! producers wire up).
//!
//! ## Why local composition first
//!
//! Per ADR D9 the hot path is local and zero-network. Bascule composes the
//! bundle in-process at session establish time so a single shell launch
//! doesn't pay an RTT to QM. The same `SatBundle` *can* be re-derived by
//! QM via the `ComposeSat` gRPC if a renegotiation needs the producer's
//! authoritative L1/L2/L3 layers (M4 work).
//!
//! ## Inputs
//!
//! Bascule itself is identity-agnostic; the `bascule-shell` binary detects
//! the operator identity (Entra/AZ-CLI/Kerberos/cached-OIDC/system) and the
//! local platform attestation (TPM/IMA/Keylime/Entra-device). Both come in
//! here as plain types so this module stays free of OS-detection logic.
use sha2::{Digest, Sha256};
use substrate_proto::v2::builder::SatBundleBuilder;
use substrate_proto::v2::hashing::session_claim_hash;
use substrate_proto::v2::{
ActorContext, PostureEvidence, PostureLevel, SatBundle, SessionClaim,
};
/// Inputs to SAT composition. Plain data so the caller can be `bascule-shell`
/// or a future server-side handler without dragging in OS detection.
#[derive(Debug, Clone)]
pub struct SessionInputs<'a> {
/// Operator principal — `tyler@guildhouse.dev`, SPIFFE ID, system user, etc.
pub principal: &'a str,
/// Bascule's classification of how `principal` was authenticated.
/// e.g. `oidc-entra`, `kerberos`, `oidc-cached`, `ssh-key`.
pub auth_method: &'a str,
/// `human` | `agent` | `system` | `node` per ActorContext schema.
pub actor_type: &'a str,
/// Did this principal acquire a fresh attestation token alongside the
/// shell session (Entra access token, Kerberos TGT)?
pub identity_verified: bool,
/// Was a TPM quote read at attestation time?
pub platform_attested: bool,
/// Did the local image hash match the expected/attested value?
/// Bascule today doesn't compute this; pass `false` until the loader
/// produces a verdict.
pub software_verified: bool,
/// Optional hex string seed used to derive a deterministic nonce.
/// Pass `None` for a random nonce.
pub nonce_seed: Option<&'a str>,
}
/// Outcome of SAT composition.
pub struct ComposedSat {
/// The full proto bundle (only the L4 SessionClaim is populated today).
pub bundle: SatBundle,
/// Hex-encoded `bundle.sat_hash`.
pub sat_hash_hex: String,
/// Hex-encoded `session_claim.claim_hash`.
pub session_claim_hash_hex: String,
}
/// Build a `SessionClaim` from session inputs. Pure function; no I/O.
pub fn build_session_claim(inputs: &SessionInputs<'_>) -> SessionClaim {
let evidence = PostureEvidence {
platform_attested: inputs.platform_attested,
platform_method: if inputs.platform_attested {
"tpm_quote".into()
} else {
"none".into()
},
software_verified: inputs.software_verified,
software_method: if inputs.software_verified {
"hash_match".into()
} else {
"none".into()
},
// Bascule does not bind to a Quartermaster-issued accord at session
// establish time; M4 will set this when the Accord verdict pipeline
// returns a permit.
governance_bound: false,
governance_method: "none".into(),
identity_verified: inputs.identity_verified,
identity_method: inputs.auth_method.to_string(),
};
let posture_level = compute_posture(&evidence);
let nonce = match inputs.nonce_seed {
Some(seed) => Sha256::digest(seed.as_bytes()).to_vec(),
None => {
use rand::RngCore;
let mut buf = vec![0u8; 16];
rand::thread_rng().fill_bytes(&mut buf);
buf
}
};
let mut claim = SessionClaim {
layer: 4,
session_id: uuid::Uuid::new_v4().to_string(),
actor: Some(ActorContext {
actor_id: inputs.principal.to_string(),
actor_type: inputs.actor_type.to_string(),
auth_method: inputs.auth_method.to_string(),
delegated_by: None,
delegation_id: None,
}),
posture_evidence: Some(evidence),
posture_level: posture_level as i32,
timestamp: chrono::Utc::now().to_rfc3339(),
nonce,
// L1/L2/L3 binding hashes are zero-padded until upstream producers
// exist. The L4 leaf hash is still verifiable on its own.
platform_claim_hash: vec![0u8; 32],
software_claim_hash: vec![0u8; 32],
governance_claim_hash: vec![0u8; 32],
claim_hash: Vec::new(),
};
claim.claim_hash = session_claim_hash(&claim).to_vec();
claim
}
/// Compose a SAT bundle locally from a `SessionClaim`. The other layers are
/// `None` until the corresponding producers are wired in. The composite
/// `sat_hash` covers all four slots (zero-padded for absent layers), so the
/// hash is comparable to one assembled later by QM with full layers.
pub fn compose_local(claim: SessionClaim) -> ComposedSat {
let session_claim_hash_hex = hex::encode(&claim.claim_hash);
let bundle = SatBundleBuilder::new().session(claim).build();
let sat_hash_hex = hex::encode(&bundle.sat_hash);
ComposedSat {
bundle,
sat_hash_hex,
session_claim_hash_hex,
}
}
/// Convenience: build_session_claim + compose_local in one call.
pub fn compose_from_inputs(inputs: &SessionInputs<'_>) -> ComposedSat {
compose_local(build_session_claim(inputs))
}
/// SAT-SPEC-0002 §7 posture computation, mirrored from substrate-common's
/// `compute_posture` so we can call it without dragging the symbol through
/// the proto facade. Kept identical on purpose.
fn compute_posture(evidence: &PostureEvidence) -> PostureLevel {
if evidence.platform_attested
&& evidence.software_verified
&& evidence.governance_bound
&& evidence.identity_verified
{
PostureLevel::Attested
} else if evidence.software_verified
&& evidence.governance_bound
&& evidence.identity_verified
{
PostureLevel::Governed
} else if evidence.software_verified && evidence.identity_verified {
PostureLevel::Verified
} else if evidence.identity_verified {
PostureLevel::Local
} else {
PostureLevel::None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inputs() -> SessionInputs<'static> {
SessionInputs {
principal: "tyler@guildhouse.dev",
auth_method: "oidc-entra",
actor_type: "human",
identity_verified: true,
platform_attested: false,
software_verified: false,
nonce_seed: Some("test-seed"),
}
}
#[test]
fn session_claim_carries_actor_and_layer4() {
let claim = build_session_claim(&inputs());
assert_eq!(claim.layer, 4);
let actor = claim.actor.as_ref().unwrap();
assert_eq!(actor.actor_id, "tyler@guildhouse.dev");
assert_eq!(actor.auth_method, "oidc-entra");
assert_eq!(actor.actor_type, "human");
assert!(!claim.claim_hash.is_empty());
}
#[test]
fn posture_identity_only_is_local() {
// identity verified but no platform/software/governance evidence
let claim = build_session_claim(&inputs());
assert_eq!(claim.posture_level, PostureLevel::Local as i32);
}
#[test]
fn posture_identity_plus_software_is_verified() {
let mut i = inputs();
i.software_verified = true;
let claim = build_session_claim(&i);
assert_eq!(claim.posture_level, PostureLevel::Verified as i32);
}
#[test]
fn posture_full_chain_is_attested_when_governance_bound() {
let mut i = inputs();
i.platform_attested = true;
i.software_verified = true;
let mut claim = build_session_claim(&i);
// Bascule sets governance_bound=false until M4 — flip it manually
// to confirm the math.
let mut ev = claim.posture_evidence.take().unwrap();
ev.governance_bound = true;
ev.governance_method = "qm_live".into();
claim.posture_evidence = Some(ev);
let bumped = compute_posture(claim.posture_evidence.as_ref().unwrap());
assert_eq!(bumped, PostureLevel::Attested);
}
#[test]
fn nonce_seed_is_deterministic() {
let a = build_session_claim(&inputs());
let b = build_session_claim(&inputs());
assert_eq!(a.nonce, b.nonce);
// session_id and timestamp differ, so the leaf hashes still differ
// — that's intentional, the nonce is not the only freshness source.
}
#[test]
fn compose_local_produces_nonempty_sat_hash() {
let composed = compose_from_inputs(&inputs());
assert!(!composed.bundle.sat_hash.is_empty());
assert_eq!(composed.bundle.sat_version, 2);
assert!(composed.bundle.session_claim.is_some());
assert!(composed.bundle.platform_claim.is_none());
assert!(composed.bundle.software_claim.is_none());
assert!(composed.bundle.governance_claim.is_none());
assert_eq!(composed.sat_hash_hex.len(), 64); // sha256 hex
assert_eq!(composed.session_claim_hash_hex.len(), 64);
}
#[test]
fn sat_hash_changes_when_principal_changes() {
let a = compose_from_inputs(&inputs());
let mut other = inputs();
other.principal = "alice@guildhouse.dev";
let b = compose_from_inputs(&other);
assert_ne!(a.sat_hash_hex, b.sat_hash_hex);
}
}

View file

@ -0,0 +1,142 @@
//! Session store — tracks active and historical sessions for the management API.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::session::SessionInfo;
/// A session as the API exposes it.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StoredSession {
pub session_id: String,
pub principal: String,
pub auth_method: String,
pub source_ip: String,
pub backend: String,
pub connected_at: String,
pub tpm_attested: bool,
pub attestation_hash: Option<String>,
pub commands_executed: u64,
}
/// Aggregate stats.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SessionStats {
pub active_sessions: usize,
pub total_sessions: u64,
pub auth_breakdown: HashMap<String, u64>,
pub backend_breakdown: HashMap<String, u64>,
pub attested_percentage: f64,
pub failed_auth: u64,
pub peak_concurrent: usize,
}
/// In-memory session store shared between SSH handler and management API.
#[derive(Clone)]
pub struct SessionStore {
active: Arc<RwLock<HashMap<String, StoredSession>>>,
history: Arc<RwLock<Vec<StoredSession>>>,
total: Arc<RwLock<u64>>,
tpm_count: Arc<RwLock<u64>>,
peak: Arc<RwLock<usize>>,
failed_auth: Arc<RwLock<u64>>,
}
impl SessionStore {
pub fn new() -> Self {
Self {
active: Arc::new(RwLock::new(HashMap::new())),
history: Arc::new(RwLock::new(Vec::new())),
total: Arc::new(RwLock::new(0)),
tpm_count: Arc::new(RwLock::new(0)),
peak: Arc::new(RwLock::new(0)),
failed_auth: Arc::new(RwLock::new(0)),
}
}
pub async fn session_started(&self, info: &SessionInfo) {
let session = StoredSession {
session_id: info.session_id.clone(),
principal: info.principal.clone(),
auth_method: info.auth_method.clone(),
source_ip: info.source_ip.clone(),
backend: "pty".to_string(),
connected_at: info.connected_at.to_rfc3339(),
tpm_attested: false,
attestation_hash: None,
commands_executed: 0,
};
let mut active = self.active.write().await;
active.insert(info.session_id.clone(), session);
*self.total.write().await += 1;
let count = active.len();
let mut peak = self.peak.write().await;
if count > *peak { *peak = count; }
}
pub async fn session_ended(&self, session_id: &str) {
let mut active = self.active.write().await;
if let Some(session) = active.remove(session_id) {
let mut history = self.history.write().await;
history.push(session);
let excess = history.len().saturating_sub(500);
if excess > 0 { history.drain(0..excess); }
}
}
pub async fn command_executed(&self, session_id: &str) {
let mut active = self.active.write().await;
if let Some(s) = active.get_mut(session_id) {
s.commands_executed += 1;
}
}
pub async fn auth_failed(&self) {
*self.failed_auth.write().await += 1;
}
pub async fn active_sessions(&self) -> Vec<StoredSession> {
self.active.read().await.values().cloned().collect()
}
pub async fn recent_history(&self, limit: usize) -> Vec<StoredSession> {
let h = self.history.read().await;
let start = h.len().saturating_sub(limit);
h[start..].to_vec()
}
pub async fn stats(&self) -> SessionStats {
let active = self.active.read().await;
let total = *self.total.read().await;
let peak = *self.peak.read().await;
let failed = *self.failed_auth.read().await;
let tpm = *self.tpm_count.read().await;
let mut auth = HashMap::new();
let mut backend = HashMap::new();
for s in active.values() {
*auth.entry(s.auth_method.clone()).or_insert(0u64) += 1;
*backend.entry(s.backend.clone()).or_insert(0u64) += 1;
}
let attested_pct = if total > 0 { (tpm as f64 / total as f64) * 100.0 } else { 0.0 };
SessionStats {
active_sessions: active.len(),
total_sessions: total,
auth_breakdown: auth,
backend_breakdown: backend,
attested_percentage: attested_pct,
failed_auth: failed,
peak_concurrent: peak,
}
}
}
impl Default for SessionStore {
fn default() -> Self { Self::new() }
}

View file

@ -0,0 +1,12 @@
[package]
name = "bascule-dashboard-web"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Bascule dashboard — web (WASM) target"
[dependencies]
bascule-dashboard = { path = "../bascule-dashboard" }
dioxus = { version = "0.6", features = ["web"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,139 @@
:root {
--bg-primary: #0f1117;
--bg-secondary: #1a1d27;
--bg-card: #222633;
--text-primary: #e4e7ef;
--text-secondary: #8b8fa3;
--accent-primary: #6c8cff;
--accent-success: #4caf82;
--accent-warning: #e8a838;
--accent-danger: #e85454;
--border: #2a2e3d;
--radius: 8px;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Inter', -apple-system, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fc;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a1d27;
--text-secondary: #6b7280;
--border: #e5e7eb;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
}
.dashboard { min-height: 100vh; }
.dashboard-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.dashboard-header h1 { font-size: 1.2em; }
.dashboard-main {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: 700;
font-family: var(--font-mono);
}
.stat-label {
font-size: 0.8em;
color: var(--text-secondary);
margin-top: 4px;
}
.stat-primary .stat-value { color: var(--accent-primary); }
.stat-secondary .stat-value { color: var(--text-secondary); }
.stat-success .stat-value { color: var(--accent-success); }
.stat-warning .stat-value { color: var(--accent-warning); }
.stat-danger .stat-value { color: var(--accent-danger); }
.session-table { margin-top: 24px; }
.session-table h2 { margin-bottom: 12px; }
table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.85em;
}
th {
text-align: left;
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
font-weight: 500;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
background: var(--bg-secondary);
color: var(--text-secondary);
}
.image-tag { font-size: 0.8em; color: var(--text-secondary); }
.empty { color: var(--text-secondary); padding: 24px; text-align: center; }
.status-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85em;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-connected { background: var(--accent-success); }
.status-disconnected { background: var(--accent-danger); }
.status-text { color: var(--text-secondary); }
.refresh-time { color: var(--text-secondary); font-size: 0.8em; }

View file

@ -0,0 +1,63 @@
//! Bascule Dashboard — Web (WASM) entry point.
use dioxus::prelude::*;
use bascule_dashboard::components::session_table::SessionTable;
use bascule_dashboard::components::stats_cards::StatsCards;
use bascule_dashboard::components::status_bar::StatusBar;
use bascule_dashboard::types::{DashboardSession, DashboardStats};
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
// For now, use placeholder data. In production, fetch from /api/sessions.
let sessions = vec![
DashboardSession {
id: "sess-001".into(),
principal: "tking@guildhouse.dev".into(),
auth_method: "ssh-key".into(),
source_ip: "192.168.1.10".into(),
backend: "container".into(),
container_image: Some("k8s-ops".into()),
connected_at: "2026-04-05T10:00:00Z".into(),
tpm_attested: true,
attestation_hash: Some("e9b95f002f54222d".into()),
commands_executed: 47,
},
DashboardSession {
id: "sess-002".into(),
principal: "agent:claude-code".into(),
auth_method: "agent-id".into(),
source_ip: "10.0.1.50".into(),
backend: "pty".into(),
container_image: None,
connected_at: "2026-04-05T10:15:00Z".into(),
tpm_attested: false,
attestation_hash: None,
commands_executed: 12,
},
];
let stats = DashboardStats {
active_sessions: 2,
total_sessions_24h: 47,
attested_percentage: 78.0,
failed_auth_24h: 3,
..Default::default()
};
rsx! {
div { class: "dashboard",
header { class: "dashboard-header",
h1 { "Bascule" }
StatusBar { connected: true, last_refresh: Some("just now".into()) }
}
main { class: "dashboard-main",
StatsCards { stats: stats }
SessionTable { sessions: sessions }
}
}
}
}

View file

@ -0,0 +1,12 @@
[package]
name = "bascule-dashboard"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Dashboard components for Bascule SSH proxy"
[dependencies]
dioxus = "0.6"
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View file

@ -0,0 +1,3 @@
pub mod session_table;
pub mod stats_cards;
pub mod status_bar;

View file

@ -0,0 +1,48 @@
use dioxus::prelude::*;
use crate::types::DashboardSession;
#[component]
pub fn SessionTable(sessions: Vec<DashboardSession>) -> Element {
rsx! {
div { class: "session-table",
h2 { "Active Sessions ({sessions.len()})" }
if sessions.is_empty() {
p { class: "empty", "No active sessions" }
} else {
table {
thead {
tr {
th { "Principal" }
th { "Method" }
th { "Backend" }
th { "Source IP" }
th { "TPM" }
th { "Commands" }
}
}
tbody {
for session in sessions.iter() {
tr { class: "session-row",
td { "{session.principal}" }
td {
span { class: "badge", "{session.auth_method}" }
}
td {
span { class: "badge", "{session.backend}" }
if let Some(ref img) = session.container_image {
span { class: "image-tag", " ({img})" }
}
}
td { "{session.source_ip}" }
td {
if session.tpm_attested { "" } else { "" }
}
td { "{session.commands_executed}" }
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,32 @@
use dioxus::prelude::*;
use crate::types::DashboardStats;
#[component]
pub fn StatsCards(stats: DashboardStats) -> Element {
rsx! {
div { class: "stat-grid",
StatCard { label: "Active Sessions".to_string(), value: format!("{}", stats.active_sessions), accent: "primary".to_string() }
StatCard { label: "24h Total".to_string(), value: format!("{}", stats.total_sessions_24h), accent: "secondary".to_string() }
StatCard {
label: "TPM Attested".to_string(),
value: format!("{:.0}%", stats.attested_percentage),
accent: (if stats.attested_percentage > 90.0 { "success" } else { "warning" }).to_string(),
}
StatCard {
label: "Failed Auth (24h)".to_string(),
value: format!("{}", stats.failed_auth_24h),
accent: (if stats.failed_auth_24h > 10 { "danger" } else { "success" }).to_string(),
}
}
}
}
#[component]
fn StatCard(label: String, value: String, accent: String) -> Element {
rsx! {
div { class: "stat-card stat-{accent}",
div { class: "stat-value", "{value}" }
div { class: "stat-label", "{label}" }
}
}
}

View file

@ -0,0 +1,17 @@
use dioxus::prelude::*;
#[component]
pub fn StatusBar(connected: bool, last_refresh: Option<String>) -> Element {
let status_class = if connected { "connected" } else { "disconnected" };
let status_text = if connected { "Connected" } else { "Disconnected" };
rsx! {
div { class: "status-bar",
span { class: "status-dot status-{status_class}" }
span { class: "status-text", "{status_text}" }
if let Some(ref ts) = last_refresh {
span { class: "refresh-time", "Last: {ts}" }
}
}
}
}

View file

@ -0,0 +1,6 @@
//! Bascule Dashboard — shared component library.
//!
//! Dioxus components consumed by both the web (WASM) and TUI targets.
pub mod components;
pub mod types;

View file

@ -0,0 +1,35 @@
//! Shared types between dashboard and server.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DashboardSession {
pub id: String,
pub principal: String,
pub auth_method: String,
pub source_ip: String,
pub backend: String,
pub container_image: Option<String>,
pub connected_at: String,
pub tpm_attested: bool,
pub attestation_hash: Option<String>,
pub commands_executed: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DashboardStats {
pub active_sessions: usize,
pub total_sessions_24h: u64,
pub auth_breakdown: HashMap<String, u64>,
pub backend_breakdown: HashMap<String, u64>,
pub attested_percentage: f64,
pub failed_auth_24h: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub active_sessions: usize,
}

View file

@ -10,9 +10,9 @@ name = "bascule"
path = "src/main.rs"
[features]
default = []
default = ["dashboard"]
agent-id = ["dep:bascule-auth-agent-id"]
# telemetry = [] — OTel export deferred (version compatibility WIP)
dashboard = ["dep:axum", "dep:tower-http"]
[dependencies]
bascule-core = { path = "../bascule-core" }
@ -22,6 +22,8 @@ clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
anyhow = { workspace = true }
serde_json = { workspace = true }
# OTel export deferred — version compatibility WIP
# opentelemetry, opentelemetry-otlp, opentelemetry_sdk, tracing-opentelemetry
# Management API (optional, default on)
axum = { version = "0.8", optional = true }
tower-http = { version = "0.6", features = ["fs", "cors"], optional = true }

View file

@ -0,0 +1,56 @@
//! Management API — axum HTTP server for dashboard and monitoring.
use axum::{routing::get, Json, Router};
use bascule_core::store::SessionStore;
use std::sync::Arc;
/// Build the management API router.
pub fn management_api(store: Arc<SessionStore>) -> Router {
Router::new()
.route("/api/sessions", get(list_sessions))
.route("/api/sessions/history", get(session_history))
.route("/api/stats", get(get_stats))
.route("/api/health", get(health))
.route("/api/info", get(server_info))
.with_state(store)
}
async fn list_sessions(
axum::extract::State(store): axum::extract::State<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
let sessions = store.active_sessions().await;
Json(serde_json::json!({ "sessions": sessions, "count": sessions.len() }))
}
async fn session_history(
axum::extract::State(store): axum::extract::State<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
let history = store.recent_history(100).await;
Json(serde_json::json!({ "sessions": history, "count": history.len() }))
}
async fn get_stats(
axum::extract::State(store): axum::extract::State<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
let stats = store.stats().await;
Json(serde_json::json!(stats))
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn server_info() -> Json<serde_json::Value> {
Json(serde_json::json!({
"name": "bascule",
"version": env!("CARGO_PKG_VERSION"),
"features": {
"backends": ["pty", "proxy", "container"],
"auth": ["authorized-keys", "accept-all"],
"dashboard": true,
}
}))
}

View file

@ -17,6 +17,9 @@ use bascule_core::config::BasculeConfig;
use bascule_core::hooks::DefaultHandler;
use bascule_core::server::BasculeServer;
#[cfg(feature = "dashboard")]
mod api;
#[derive(Parser)]
#[command(name = "bascule", about = "Identity-aware SSH proxy")]
struct Cli {
@ -68,7 +71,6 @@ fn build_auth_provider(config: &BasculeConfig) -> Arc<dyn AuthProvider> {
}
};
// If agent_id is also configured, compose: SSH keys + Agent ID token-as-password
#[cfg(feature = "agent-id")]
if let Some(ref agent_config) = config.auth.agent_id {
tracing::info!(tenant = %agent_config.tenant_id, "Entra Agent ID auth enabled (composite)");
@ -102,27 +104,41 @@ async fn main() -> Result<()> {
init_tracing(&config);
// Validate container config at startup (fail fast on bad values)
if let Some(ref container_config) = config.container {
container_config.validate()?;
}
let backend = if config.proxy.is_some() {
"proxy"
} else if config.container.is_some() {
"container"
} else {
"pty"
};
let backend = if config.proxy.is_some() { "proxy" }
else if config.container.is_some() { "container" }
else { "pty" };
tracing::info!(
listen = %config.listen_addr,
auth = %config.auth.mode,
backend = %backend,
shell = ?config.shell_command,
"Bascule starting"
);
// Start management API if dashboard feature is enabled
#[cfg(feature = "dashboard")]
{
let store = bascule_core::store::SessionStore::new();
let store_arc = Arc::new(store);
let mgmt_listen = config.dashboard.as_ref()
.map(|d| d.listen.clone())
.unwrap_or_else(|| "0.0.0.0:9090".to_string());
let api_store = store_arc.clone();
tokio::spawn(async move {
let router = api::management_api(api_store);
let listener = tokio::net::TcpListener::bind(&mgmt_listen).await
.expect("Failed to bind management API");
tracing::info!(listen = %mgmt_listen, "Management API started");
axum::serve(listener, router).await.expect("Management API failed");
});
}
let auth = build_auth_provider(&config);
let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?;
server.run().await

View file

@ -10,6 +10,7 @@ name = "bascule-shell"
path = "src/main.rs"
[dependencies]
bascule-core = { path = "../bascule-core" }
clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View file

@ -6,13 +6,21 @@ pub struct ShellConfig {
pub inner_shell: String,
#[serde(default = "default_true")]
pub show_banner: bool,
/// User-configured Bascule server endpoints. The bascule-shell
/// binary deserializes them from `~/.config/bascule/shell.toml`
/// but never reads them at runtime today; the shell-side server
/// chooser uses `dev.gsh` / `stg.gsh` / `prod.gsh` SSH host
/// aliases instead. Retained so the TOML schema doesn't break
/// for users who already have entries in their config.
#[serde(default)]
#[allow(dead_code)]
pub servers: Vec<BasculeServer>,
#[serde(default = "default_pcr_indices")]
pub pcr_indices: Vec<u32>,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] // TOML wire-format fields; see ShellConfig.servers comment.
pub struct BasculeServer {
pub alias: String,
pub hostname: String,

View file

@ -10,6 +10,12 @@ pub struct Identity {
}
pub fn detect() -> Identity {
// M5: SPIFFE workload identity wins when present. The SVID URI
// becomes the SAT session_leaf actor field, and QM's
// SpiffeSvidEvaluator validates it against the cluster allowlist.
// Mounted by SPIRE's k8s sidecar at the standard path; override
// via SPIFFE_SVID_PATH for tests / non-standard deploys.
if let Some(id) = detect_spiffe_svid() { return id; }
if let Some(id) = detect_entra_wsl2() { return id; }
if let Some(id) = detect_az_cli() { return id; }
if let Some(id) = detect_kerberos() { return id; }
@ -17,6 +23,35 @@ pub fn detect() -> Identity {
detect_system_user()
}
/// M5: read a SPIFFE SVID URI from a sidecar-rendered file. The
/// standard SPIRE k8s sidecar writes the URI as a single line in
/// `/var/run/spire/svid-uri` (or wherever SPIFFE_SVID_PATH points).
/// We deliberately do NOT pull the `spiffe` crate here — bascule-shell
/// stays small and the URI string is all we need for the SAT session
/// claim. The mTLS dance is QM's job (server side); bascule-shell is
/// the producer.
fn detect_spiffe_svid() -> Option<Identity> {
let path = std::env::var("SPIFFE_SVID_PATH")
.unwrap_or_else(|_| "/var/run/spire/svid-uri".to_string());
let content = std::fs::read_to_string(&path).ok()?;
let uri = content.lines().next()?.trim().to_string();
if !uri.starts_with("spiffe://") {
return None;
}
// Trust domain is the host component.
let trust_domain = uri
.strip_prefix("spiffe://")
.and_then(|rest| rest.split('/').next())
.map(|s| s.to_string());
Some(Identity {
principal: uri,
auth_method: "spiffe".into(),
domain: trust_domain,
source: format!("SPIFFE workload SVID ({})", path),
has_token: true,
})
}
fn detect_entra_wsl2() -> Option<Identity> {
if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
return None;

View file

@ -12,6 +12,7 @@ mod attestation;
mod banner;
mod config;
mod identity;
mod posture;
#[derive(Parser)]
#[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")]
@ -71,8 +72,25 @@ fn main() -> Result<()> {
banner::display(&id, &attest);
}
// M1: compose a real proto SatBundle anchored on the L4 SessionClaim,
// built from the operator identity + local platform attestation. The
// composer lives in bascule_core::sat so server-side handlers can call
// it later without dragging in bascule-shell's OS-detection logic.
let nonce_seed = format!("{}|{}", id.principal, attest.composite_hash);
let composed = bascule_core::sat::compose_from_inputs(&bascule_core::sat::SessionInputs {
principal: &id.principal,
auth_method: &id.auth_method,
actor_type: "human",
identity_verified: id.has_token,
platform_attested: attest.tpm_available && !attest.pcr_values.is_empty(),
// M2 will flip this once gsh corpus check runs and the loader
// produces a software verdict.
software_verified: false,
nonce_seed: Some(&nonce_seed),
});
// Set BASCULE_* env vars
set_env(&id, &attest);
set_env(&id, &attest, &composed);
// Determine inner shell
let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone());
@ -96,13 +114,71 @@ fn main() -> Result<()> {
anyhow::bail!("Failed to exec inner shell: {}", shell);
}
fn set_env(id: &identity::Identity, attest: &attestation::Attestation) {
fn set_env(
id: &identity::Identity,
attest: &attestation::Attestation,
composed: &bascule_core::sat::ComposedSat,
) {
std::env::set_var("BASCULE_PRINCIPAL", &id.principal);
std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method);
if let Some(ref domain) = id.domain {
std::env::set_var("BASCULE_DOMAIN", domain);
}
std::env::set_var("BASCULE_ATTESTATION_HASH", &attest.composite_hash);
// M2: export operator roles for gsh's role-aware Corpus check.
// Precedence: caller-set BASCULE_ROLES wins; otherwise derive a
// sensible default from the auth method so existing dev shells
// get *some* role context instead of empty. Real role provisioning
// (Entra group claims, SPIFFE workload roles) will replace this
// in M5; M2 just needs the env var populated so the classifier
// has something to match against.
if std::env::var_os("BASCULE_ROLES").is_none() {
let default_role = match id.auth_method.as_str() {
// M5: SPIFFE workload identities are explicitly cluster-issued
// -> trusted as operator until per-workload role provisioning
// lands in a future milestone.
"spiffe" => "operator",
"oidc-entra" | "oidc-cached" => "operator",
"kerberos" => "operator",
"ssh-key" => "apprentice",
_ => "apprentice",
};
std::env::set_var("BASCULE_ROLES", default_role);
}
// M3: load the operator's posture-current.json once at session
// start and surface the consumer-facing fields as BASCULE_*
// env vars so gsh's classifier (and any external tooling) gets
// the same view without re-reading the file. gsh ALSO reads the
// file directly via libgsh::PostureState — these env vars are
// the secondary surface for non-gsh consumers and for the
// posture-watcher fallback when the file is unreachable.
let posture = posture::PostureSnapshot::load();
std::env::set_var("BASCULE_DEFCON_LEVEL", posture.global_level.to_string());
std::env::set_var("BASCULE_POSTURE_LEVEL", posture.global_level.to_string());
std::env::set_var("BASCULE_CAPABILITY_CEILING", &posture.capability_ceiling);
std::env::set_var(
"BASCULE_CEREMONY_REQUIRED",
if posture.ceremony_required_for_writes { "1" } else { "0" },
);
if posture.max_session_ttl_minutes > 0 {
std::env::set_var(
"BASCULE_MAX_SESSION_TTL",
posture.max_session_ttl_minutes.to_string(),
);
}
// BASCULE_ATTESTATION_HASH was an opaque "evidence string" SHA. M1
// replaces it with the proto SAT composite hash. Kept under the same
// env var name for backward compatibility with existing gsh consumers
// (and a NEW BASCULE_SAT_HASH alias for the renamed surface).
std::env::set_var("BASCULE_ATTESTATION_HASH", &composed.sat_hash_hex);
std::env::set_var("BASCULE_SAT_HASH", &composed.sat_hash_hex);
std::env::set_var("BASCULE_SESSION_CLAIM_HASH", &composed.session_claim_hash_hex);
if let Some(ref claim) = composed.bundle.session_claim {
std::env::set_var("BASCULE_SESSION_ID", &claim.session_id);
std::env::set_var("BASCULE_POSTURE_LEVEL", claim.posture_level.to_string());
}
std::env::set_var("BASCULE_TPM_AVAILABLE", attest.tpm_available.to_string());
std::env::set_var("BASCULE_PCR_COUNT", attest.pcr_values.len().to_string());
if let Some(ref ima_hash) = attest.ima_log_hash {

View file

@ -0,0 +1,51 @@
//! M3: minimal DEFCON posture loader for bascule-shell.
//!
//! Reads `/opt/substrate/posture/current.json` (override via
//! `BASCULE_POSTURE_FILE`), exposes a few fields the env var exporter
//! cares about, and falls back gracefully if the file is missing or
//! malformed. The struct is intentionally NOT shared with libgsh's
//! richer `PostureState` — bascule-shell ships independently from gsh
//! and the cross-workspace dep would force cargo to compile libgsh just
//! to pull a serde struct. Keep them in sync via the JSON wire format
//! (`current.json`), which substrate-operator owns.
use std::path::PathBuf;
use serde::Deserialize;
pub const DEFAULT_POSTURE_FILE: &str = "/opt/substrate/posture/current.json";
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PostureSnapshot {
pub global_level: u8,
pub max_session_ttl_minutes: i64,
pub ceremony_required_for_writes: bool,
pub capability_ceiling: String,
}
impl Default for PostureSnapshot {
fn default() -> Self {
Self {
global_level: 5,
max_session_ttl_minutes: 0,
ceremony_required_for_writes: false,
capability_ceiling: "CAP_GOVERN".into(),
}
}
}
impl PostureSnapshot {
/// Load the operator's posture-current.json. Never errors:
/// missing/malformed files degrade to peacetime defaults so the
/// shell exec path stays alive on misconfigured hosts.
pub fn load() -> Self {
let path = std::env::var("BASCULE_POSTURE_FILE")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_POSTURE_FILE));
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Self>(&s).ok())
.unwrap_or_default()
}
}

View file

@ -2,20 +2,28 @@
## Overview
Bascule is a single-binary SSH proxy with three pluggable layers:
Bascule is a single-binary SSH proxy with a built-in management API:
```
Client SSH → Bascule Server
├── AuthProvider (SSH keys / OIDC / Agent ID)
├── SessionHandler hooks
│ on_session_start → build_session_env → on_exec → on_session_end
└── SessionBackend
├── LocalPty (portable-pty → /bin/bash)
├── RemoteProxy (upstream SSH → target host)
└── Container (docker/podman → ephemeral image)
┌─────────────────────────────────────┐
Operator workstation │ bascule (single binary) │
┌───────────────┐ │ │
│ bascule-shell │ SSH │ Port 2222: SSH Proxy (russh) │
│ identity + │─────▶│ ├── AuthProvider │
│ TPM attest │ │ ├── SessionHandler hooks │
└───────────────┘ │ └── SessionBackend │
│ ├── Local PTY │
Browser / curl │ ├── Remote Proxy │
┌───────────────┐ HTTP │ └── Container │
│ Dashboard │─────▶│ │
│ /api/* │ │ Port 9090: Management API (axum) │
└───────────────┘ │ ├── /api/sessions │
│ ├── /api/stats │
│ ├── /api/health │
│ └── /dashboard (WASM, planned) │
│ │
│ Arc<SessionStore> shared │
└─────────────────────────────────────┘
```
## Session Backends
@ -81,10 +89,19 @@ When `ephemeral = true` (default), the container is `--rm` and destroyed on disc
Set `network = "none"` to completely isolate the container from the network. The operator can run local tools but can't reach external services.
## Management API
The management API runs alongside the SSH proxy in the same process. Both share an `Arc<SessionStore>` — when a session starts via SSH, the store updates; when the dashboard polls `/api/sessions`, it reads the same store. Zero serialization, zero IPC.
Enable via `[dashboard]` config section or `--features dashboard` (default on).
## Crate Structure
| Crate | Purpose |
|-------|---------|
| `bascule-core` | Library — server, handler, auth, PTY, proxy, container, hooks |
| `bascule-server` | Binary — CLI, config loading, tracing setup |
| `bascule-core` | Library — server, handler, auth, PTY, proxy, container, hooks, store |
| `bascule-server` | Binary — CLI, config, tracing, management API |
| `bascule-auth-agent-id` | Optional — Entra Agent ID authentication |
| `bascule-shell` | Binary — Identity-aware login shell with TPM |
| `bascule-dashboard` | Library — Dioxus UI components |
| `bascule-dashboard-web` | Binary — WASM web dashboard target |

View file

@ -14,6 +14,8 @@
| Extensibility | SessionHandler trait | Plugin system | No | No |
| Proxy mode | Built-in | Built-in | Built-in | SaaS |
| Config | Single TOML file | Complex YAML | Complex HCL | Web UI |
| Built-in dashboard | Yes (port 9090) | Yes | No | Yes (SaaS) |
| TPM attestation | Yes (bascule-shell) | No | No | No |
## When to choose Bascule

View file

@ -111,6 +111,17 @@ memory_limit = "512m"
network = "none"
```
## `[dashboard]`
Management API and dashboard (requires `--features dashboard`, default on).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable management API |
| `listen` | string | `0.0.0.0:9090` | Listen address for HTTP API |
## Example Configs
### Jumphost (proxy)
```toml

View file

@ -38,6 +38,23 @@ RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only
| Upstream connected | INFO | Proxy session connected to target |
| Session ended | INFO | Disconnect or exit |
## Management API
The built-in management API (port 9090) provides real-time session monitoring:
```bash
# Active sessions
curl http://localhost:9090/api/sessions
# Aggregate stats (auth breakdown, TPM %, peak concurrent)
curl http://localhost:9090/api/stats
# Server health
curl http://localhost:9090/api/health
```
Configure via `[dashboard]` in your config file. See [configuration.md](configuration.md).
## OTel Tracing (Planned)
OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Not yet implemented. Session lifecycle will map to OTel spans:

View file

@ -56,6 +56,21 @@ TOML
./target/release/bascule --config proxy-config.toml
```
## Management API
The management API starts automatically on port 9090:
```bash
# Check server health
curl http://localhost:9090/api/health
# View active sessions
curl http://localhost:9090/api/sessions
# Aggregate stats
curl http://localhost:9090/api/stats
```
## Next Steps
- [Configuration Reference](configuration.md)