Compare commits
No commits in common. "984a37e0cbc097ff2e07cd6ab4e565ec1820120672a5477f1e4d57d089380fa3" and "2fa92f8635853f6fc496dbe564995da3b1fe502465559e74f652f180c41bc587" have entirely different histories.
984a37e0cb
...
2fa92f8635
39 changed files with 51 additions and 2956 deletions
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
|
|
@ -11,35 +11,6 @@ env:
|
||||||
RUSTFLAGS: -Dwarnings
|
RUSTFLAGS: -Dwarnings
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,10 @@ Bascule is an identity-aware SSH proxy. It authenticates operators via SSH keys
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
|
|
||||||
- `crates/bascule-core/` — Library: SSH server, auth, session backends, hooks, session store
|
- `crates/bascule-core/` — Library: SSH server, auth, session backends, hooks
|
||||||
- `crates/bascule-server/` — Binary: SSH proxy + embedded management API (axum)
|
- `crates/bascule-server/` — Binary: CLI wrapper, config loading, telemetry setup
|
||||||
- `crates/bascule-auth-agent-id/` — Optional: Entra Agent ID auth provider
|
- `crates/bascule-auth-agent-id/` — Optional: Entra Agent ID auth provider
|
||||||
- `crates/bascule-shell/` — Binary: Identity-aware login shell with TPM attestation
|
- `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
|
- `charts/bascule/` — Helm chart for K8s deployment
|
||||||
- `images/` — Curated container images for operator environments
|
- `images/` — Curated container images for operator environments
|
||||||
|
|
||||||
|
|
@ -32,7 +30,6 @@ make dev # Run locally in dev mode
|
||||||
|
|
||||||
## Feature flags (bascule-server)
|
## Feature flags (bascule-server)
|
||||||
|
|
||||||
- `dashboard` — Management API on port 9090 (default on)
|
|
||||||
- `agent-id` — Entra Agent ID auth
|
- `agent-id` — Entra Agent ID auth
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,10 @@ Bascule is a Rust workspace:
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `bascule-core` | Library — SSH server, auth, PTY, proxy, container, hooks, store |
|
| `bascule-core` | Library — SSH server, auth, PTY, proxy, container, hooks |
|
||||||
| `bascule-server` | Binary — SSH proxy + management API (axum) |
|
| `bascule-server` | Binary — CLI, config, telemetry |
|
||||||
| `bascule-auth-agent-id` | Optional — Entra Agent ID auth |
|
| `bascule-auth-agent-id` | Optional — Entra Agent ID auth |
|
||||||
| `bascule-shell` | Binary — Identity-aware login shell with TPM |
|
| `bascule-shell` | Binary — Identity-aware login shell |
|
||||||
| `bascule-dashboard` | Library — Dioxus UI components |
|
|
||||||
| `bascule-dashboard-web` | Binary — WASM web dashboard target |
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -47,38 +45,6 @@ cargo test --all
|
||||||
Format: `type: description`
|
Format: `type: description`
|
||||||
Types: feat, fix, docs, chore, refactor, test
|
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
|
## License
|
||||||
|
|
||||||
By contributing, you agree your contributions are licensed under Apache 2.0.
|
By contributing, you agree your contributions are licensed under Apache 2.0.
|
||||||
|
|
|
||||||
1393
Cargo.lock
generated
1393
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
|
@ -1,12 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id", "crates/bascule-shell"]
|
||||||
"crates/bascule-core",
|
|
||||||
"crates/bascule-server",
|
|
||||||
"crates/bascule-auth-agent-id",
|
|
||||||
"crates/bascule-shell",
|
|
||||||
"crates/bascule-dashboard",
|
|
||||||
"crates/bascule-dashboard-web",
|
|
||||||
]
|
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -15,18 +8,6 @@ edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[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 = "0.46"
|
||||||
russh-keys = "0.46"
|
russh-keys = "0.46"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
34
DCO
34
DCO
|
|
@ -1,34 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# 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
59
NOTICE
|
|
@ -1,59 +0,0 @@
|
||||||
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.
|
|
||||||
20
README.md
20
README.md
|
|
@ -41,19 +41,10 @@ See [docs/quickstart.md](docs/quickstart.md) for Docker, Helm, and container mod
|
||||||
- Read-only rootfs option
|
- Read-only rootfs option
|
||||||
- NetworkPolicy for Kubernetes deployments
|
- 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
|
### Observability
|
||||||
|
|
||||||
- Structured JSON logging (`BASCULE_LOG_FORMAT=json`)
|
- Structured JSON logging (`BASCULE_LOG_FORMAT=json`)
|
||||||
- Tracing spans on auth, session lifecycle, exec requests
|
- Tracing spans on auth, session lifecycle, exec requests
|
||||||
- Management API for real-time session monitoring
|
|
||||||
|
|
||||||
## Client: bascule-shell
|
## Client: bascule-shell
|
||||||
|
|
||||||
|
|
@ -85,7 +76,6 @@ See [docs/bascule-shell.md](docs/bascule-shell.md).
|
||||||
| Container sessions | Yes | No | No |
|
| Container sessions | Yes | No | No |
|
||||||
| AI Agent Identity | Yes (Entra Agent ID) | No | No |
|
| AI Agent Identity | Yes (Entra Agent ID) | No | No |
|
||||||
| Binary size | ~7MB | ~150MB | ~100MB |
|
| Binary size | ~7MB | ~150MB | ~100MB |
|
||||||
| Built-in dashboard | Yes (port 9090) | Yes | No |
|
|
||||||
|
|
||||||
See [docs/comparison.md](docs/comparison.md).
|
See [docs/comparison.md](docs/comparison.md).
|
||||||
|
|
||||||
|
|
@ -115,16 +105,6 @@ impl SessionHandler for AuditHandler {
|
||||||
|
|
||||||
See [docs/architecture.md](docs/architecture.md).
|
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
|
## Roadmap
|
||||||
|
|
||||||
Not yet implemented:
|
Not yet implemented:
|
||||||
|
|
|
||||||
|
|
@ -79,12 +79,6 @@ mode = "accept-all"
|
||||||
# shell_container = "shell"
|
# shell_container = "shell"
|
||||||
# shell = "/bin/bash"
|
# shell = "/bin/bash"
|
||||||
|
|
||||||
# ─── Dashboard / Management API ─────────────────────────
|
|
||||||
# Enabled by default with --features dashboard
|
|
||||||
[dashboard]
|
|
||||||
enabled = true
|
|
||||||
listen = "0.0.0.0:9090"
|
|
||||||
|
|
||||||
# ─── Telemetry ──────────────────────────────────────────
|
# ─── Telemetry ──────────────────────────────────────────
|
||||||
# [telemetry]
|
# [telemetry]
|
||||||
# otlp_endpoint = "http://localhost:4317"
|
# otlp_endpoint = "http://localhost:4317"
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,6 @@ chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
portable-pty = { 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]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,6 @@ pub struct BasculeConfig {
|
||||||
/// Prometheus metrics.
|
/// Prometheus metrics.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metrics: MetricsConfig,
|
pub metrics: MetricsConfig,
|
||||||
|
|
||||||
/// Dashboard / management API.
|
|
||||||
pub dashboard: Option<DashboardConfig>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
|
@ -128,7 +125,6 @@ impl Default for BasculeConfig {
|
||||||
k8s: None,
|
k8s: None,
|
||||||
telemetry: TelemetryConfig::default(),
|
telemetry: TelemetryConfig::default(),
|
||||||
metrics: MetricsConfig::default(),
|
metrics: MetricsConfig::default(),
|
||||||
dashboard: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -354,19 +350,6 @@ fn default_metrics_port() -> u16 {
|
||||||
9090
|
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 {
|
fn default_runtime() -> String {
|
||||||
"auto".to_string()
|
"auto".to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,5 @@ pub mod handler;
|
||||||
pub mod hooks;
|
pub mod hooks;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod sat;
|
|
||||||
#[cfg(feature = "hfl")]
|
|
||||||
pub mod hfl_sat;
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod store;
|
|
||||||
|
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
//! 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() }
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[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"
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
: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; }
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
//! 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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[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 }
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
pub mod session_table;
|
|
||||||
pub mod stats_cards;
|
|
||||||
pub mod status_bar;
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
//! Bascule Dashboard — shared component library.
|
|
||||||
//!
|
|
||||||
//! Dioxus components consumed by both the web (WASM) and TUI targets.
|
|
||||||
|
|
||||||
pub mod components;
|
|
||||||
pub mod types;
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
//! 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,
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,9 @@ name = "bascule"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["dashboard"]
|
default = []
|
||||||
agent-id = ["dep:bascule-auth-agent-id"]
|
agent-id = ["dep:bascule-auth-agent-id"]
|
||||||
dashboard = ["dep:axum", "dep:tower-http"]
|
# telemetry = [] — OTel export deferred (version compatibility WIP)
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bascule-core = { path = "../bascule-core" }
|
bascule-core = { path = "../bascule-core" }
|
||||||
|
|
@ -22,8 +22,6 @@ clap = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
|
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
# Management API (optional, default on)
|
# OTel export deferred — version compatibility WIP
|
||||||
axum = { version = "0.8", optional = true }
|
# opentelemetry, opentelemetry-otlp, opentelemetry_sdk, tracing-opentelemetry
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"], optional = true }
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
//! 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,
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
@ -17,9 +17,6 @@ use bascule_core::config::BasculeConfig;
|
||||||
use bascule_core::hooks::DefaultHandler;
|
use bascule_core::hooks::DefaultHandler;
|
||||||
use bascule_core::server::BasculeServer;
|
use bascule_core::server::BasculeServer;
|
||||||
|
|
||||||
#[cfg(feature = "dashboard")]
|
|
||||||
mod api;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bascule", about = "Identity-aware SSH proxy")]
|
#[command(name = "bascule", about = "Identity-aware SSH proxy")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
|
@ -71,6 +68,7 @@ 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")]
|
#[cfg(feature = "agent-id")]
|
||||||
if let Some(ref agent_config) = config.auth.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)");
|
tracing::info!(tenant = %agent_config.tenant_id, "Entra Agent ID auth enabled (composite)");
|
||||||
|
|
@ -104,41 +102,27 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
init_tracing(&config);
|
init_tracing(&config);
|
||||||
|
|
||||||
|
// Validate container config at startup (fail fast on bad values)
|
||||||
if let Some(ref container_config) = config.container {
|
if let Some(ref container_config) = config.container {
|
||||||
container_config.validate()?;
|
container_config.validate()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let backend = if config.proxy.is_some() { "proxy" }
|
let backend = if config.proxy.is_some() {
|
||||||
else if config.container.is_some() { "container" }
|
"proxy"
|
||||||
else { "pty" };
|
} else if config.container.is_some() {
|
||||||
|
"container"
|
||||||
|
} else {
|
||||||
|
"pty"
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listen = %config.listen_addr,
|
listen = %config.listen_addr,
|
||||||
auth = %config.auth.mode,
|
auth = %config.auth.mode,
|
||||||
backend = %backend,
|
backend = %backend,
|
||||||
|
shell = ?config.shell_command,
|
||||||
"Bascule starting"
|
"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 auth = build_auth_provider(&config);
|
||||||
let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?;
|
let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?;
|
||||||
server.run().await
|
server.run().await
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ name = "bascule-shell"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bascule-core = { path = "../bascule-core" }
|
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,13 @@ pub struct ShellConfig {
|
||||||
pub inner_shell: String,
|
pub inner_shell: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_banner: bool,
|
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)]
|
#[serde(default)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub servers: Vec<BasculeServer>,
|
pub servers: Vec<BasculeServer>,
|
||||||
#[serde(default = "default_pcr_indices")]
|
#[serde(default = "default_pcr_indices")]
|
||||||
pub pcr_indices: Vec<u32>,
|
pub pcr_indices: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[allow(dead_code)] // TOML wire-format fields; see ShellConfig.servers comment.
|
|
||||||
pub struct BasculeServer {
|
pub struct BasculeServer {
|
||||||
pub alias: String,
|
pub alias: String,
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ pub struct Identity {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect() -> 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_entra_wsl2() { return id; }
|
||||||
if let Some(id) = detect_az_cli() { return id; }
|
if let Some(id) = detect_az_cli() { return id; }
|
||||||
if let Some(id) = detect_kerberos() { return id; }
|
if let Some(id) = detect_kerberos() { return id; }
|
||||||
|
|
@ -23,35 +17,6 @@ pub fn detect() -> Identity {
|
||||||
detect_system_user()
|
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> {
|
fn detect_entra_wsl2() -> Option<Identity> {
|
||||||
if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
|
if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
|
||||||
return None;
|
return None;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ mod attestation;
|
||||||
mod banner;
|
mod banner;
|
||||||
mod config;
|
mod config;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod posture;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")]
|
#[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")]
|
||||||
|
|
@ -72,25 +71,8 @@ fn main() -> Result<()> {
|
||||||
banner::display(&id, &attest);
|
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 BASCULE_* env vars
|
||||||
set_env(&id, &attest, &composed);
|
set_env(&id, &attest);
|
||||||
|
|
||||||
// Determine inner shell
|
// Determine inner shell
|
||||||
let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone());
|
let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone());
|
||||||
|
|
@ -114,71 +96,13 @@ fn main() -> Result<()> {
|
||||||
anyhow::bail!("Failed to exec inner shell: {}", shell);
|
anyhow::bail!("Failed to exec inner shell: {}", shell);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_env(
|
fn set_env(id: &identity::Identity, attest: &attestation::Attestation) {
|
||||||
id: &identity::Identity,
|
|
||||||
attest: &attestation::Attestation,
|
|
||||||
composed: &bascule_core::sat::ComposedSat,
|
|
||||||
) {
|
|
||||||
std::env::set_var("BASCULE_PRINCIPAL", &id.principal);
|
std::env::set_var("BASCULE_PRINCIPAL", &id.principal);
|
||||||
std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method);
|
std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method);
|
||||||
if let Some(ref domain) = id.domain {
|
if let Some(ref domain) = id.domain {
|
||||||
std::env::set_var("BASCULE_DOMAIN", 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_TPM_AVAILABLE", attest.tpm_available.to_string());
|
||||||
std::env::set_var("BASCULE_PCR_COUNT", attest.pcr_values.len().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 {
|
if let Some(ref ima_hash) = attest.ima_log_hash {
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
//! 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,28 +2,20 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Bascule is a single-binary SSH proxy with a built-in management API:
|
Bascule is a single-binary SSH proxy with three pluggable layers:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────┐
|
Client SSH → Bascule Server
|
||||||
Operator workstation │ bascule (single binary) │
|
│
|
||||||
┌───────────────┐ │ │
|
├── AuthProvider (SSH keys / OIDC / Agent ID)
|
||||||
│ bascule-shell │ SSH │ Port 2222: SSH Proxy (russh) │
|
│
|
||||||
│ identity + │─────▶│ ├── AuthProvider │
|
├── SessionHandler hooks
|
||||||
│ TPM attest │ │ ├── SessionHandler hooks │
|
│ on_session_start → build_session_env → on_exec → on_session_end
|
||||||
└───────────────┘ │ └── SessionBackend │
|
│
|
||||||
│ ├── Local PTY │
|
└── SessionBackend
|
||||||
Browser / curl │ ├── Remote Proxy │
|
├── LocalPty (portable-pty → /bin/bash)
|
||||||
┌───────────────┐ HTTP │ └── Container │
|
├── RemoteProxy (upstream SSH → target host)
|
||||||
│ Dashboard │─────▶│ │
|
└── Container (docker/podman → ephemeral image)
|
||||||
│ /api/* │ │ Port 9090: Management API (axum) │
|
|
||||||
└───────────────┘ │ ├── /api/sessions │
|
|
||||||
│ ├── /api/stats │
|
|
||||||
│ ├── /api/health │
|
|
||||||
│ └── /dashboard (WASM, planned) │
|
|
||||||
│ │
|
|
||||||
│ Arc<SessionStore> shared │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Backends
|
## Session Backends
|
||||||
|
|
@ -89,19 +81,10 @@ 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.
|
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 Structure
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `bascule-core` | Library — server, handler, auth, PTY, proxy, container, hooks, store |
|
| `bascule-core` | Library — server, handler, auth, PTY, proxy, container, hooks |
|
||||||
| `bascule-server` | Binary — CLI, config, tracing, management API |
|
| `bascule-server` | Binary — CLI, config loading, tracing setup |
|
||||||
| `bascule-auth-agent-id` | Optional — Entra Agent ID authentication |
|
| `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 |
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
||||||
| Proxy mode | Built-in | Built-in | Built-in | SaaS |
|
| Proxy mode | Built-in | Built-in | Built-in | SaaS |
|
||||||
| Config | Single TOML file | Complex YAML | Complex HCL | Web UI |
|
| 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
|
## When to choose Bascule
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,17 +111,6 @@ memory_limit = "512m"
|
||||||
network = "none"
|
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)
|
### Jumphost (proxy)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|
|
||||||
|
|
@ -38,23 +38,6 @@ RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only
|
||||||
| Upstream connected | INFO | Proxy session connected to target |
|
| Upstream connected | INFO | Proxy session connected to target |
|
||||||
| Session ended | INFO | Disconnect or exit |
|
| 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)
|
## 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:
|
OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Not yet implemented. Session lifecycle will map to OTel spans:
|
||||||
|
|
|
||||||
|
|
@ -56,21 +56,6 @@ TOML
|
||||||
./target/release/bascule --config proxy-config.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
|
## Next Steps
|
||||||
|
|
||||||
- [Configuration Reference](configuration.md)
|
- [Configuration Reference](configuration.md)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue