extra-forges: fully dashboard-provisioned, no host config
Per mara's feedback on PR #2407 ("better: you can also provide url in dashboard, same as with matrix, no host config"), drops services.hyperhive.extraForges and the admin-API mint/revoke flow entirely. The operator now creates a token on the external forge themselves and pastes a label + base URL + access token into the dashboard's FORGES tab, the same shape as the GitHub PAT flow plus the base-URL field from the matrix extra-account flow. hive-c0re only ever writes/deletes two local files per account (forge-<label>-token, forge-<label>.json sidecar for the URL) via hive-priv — no remote account creation, no admin token, no revoke-on-the-remote-side, no nix config to enumerate. - nix/host-modules/hive-forge/default.nix: removed the extraForges option, its label-format assertion, and the HYPERHIVE_EXTRA_FORGES env forwarding. - hive-c0re/src/forge/extra.rs: deleted (REST admin-API provisioning, no longer needed). - hive-c0re/src/dashboard/extra_forges.rs: GET /api/extra-forges? agent= lists an agent's stored forges by scanning its state dir (mirrors matrix_accounts.rs's filename-scan listing), POST /api/extra-forge-account (agent/label/base_url/token/ action=add|remove) stores or removes an account. - hive-sh4re/priv_proto.rs + hive-priv/main.rs: new WriteAgentExtraForgeAccount/DeleteAgentExtraForgeAccount priv requests (adds base_url, writes/deletes a JSON sidecar alongside the token). - hive-c0re/src/priv_client.rs: matching wrapper functions. - frontend/packages/dashboard/src/credentials.{html,js}: FORGES tab is a per-agent list + add-account paste form (label/base_url/token), no grant/revoke-from-catalog UI. - docs/web-ui/dashboard.md: FORGES tab section rewritten. Supersedes the design in PR #2407 (already approved+green on the old admin-API model) — opening as a fresh PR against the same issues rather than force-pushing over the approved one.
This commit is contained in:
parent
f025f32ccb
commit
dbf880ac66
10 changed files with 558 additions and 2 deletions
|
|
@ -283,7 +283,7 @@ on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link
|
||||||
than `/core.html`'s plain title. Its own esbuild bundle
|
than `/core.html`'s plain title. Its own esbuild bundle
|
||||||
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
||||||
agent picker and otherwise works off purpose-built endpoints per tab.
|
agent picker and otherwise works off purpose-built endpoints per tab.
|
||||||
Two sub-tabs:
|
Three sub-tabs:
|
||||||
|
|
||||||
### MATRIX tab
|
### MATRIX tab
|
||||||
|
|
||||||
|
|
@ -354,6 +354,32 @@ regardless of outcome. The account list reflects what is *provisioned*
|
||||||
(an account with a stored token), so a config-declared-but-unprovisioned
|
(an account with a stored token), so a config-declared-but-unprovisioned
|
||||||
account appears only once it has been provisioned through the form.
|
account appears only once it has been provisioned through the form.
|
||||||
|
|
||||||
|
### FORGES tab
|
||||||
|
|
||||||
|
Store a **label + base URL + access token** for an external Forgejo/Gitea/
|
||||||
|
Codeberg-compatible forge, per agent. Entirely dashboard-provisioned —
|
||||||
|
there is no host-side nix config for this (no `services.hyperhive.
|
||||||
|
extraForges` option). The operator creates the token on the external forge
|
||||||
|
themselves (however that forge lets them — PAT UI, a teammate with admin,
|
||||||
|
whatever) and pastes label/URL/token into the form; hive-c0re never talks
|
||||||
|
to the external forge's API and never creates an account there.
|
||||||
|
|
||||||
|
The selected agent's stored forges come from `GET /api/extra-forges?
|
||||||
|
agent=<name>` → `{ forges: [{ label, base_url }] }`, derived by scanning
|
||||||
|
the agent's state dir for `forge-<label>-token` files (mirrors the MATRIX
|
||||||
|
tab's filename-scan listing) with `base_url` backfilled from a sibling
|
||||||
|
`forge-<label>.json` sidecar. Submitting the add form posts `POST /api/
|
||||||
|
extra-forge-account` (form-encoded `agent, label, base_url, token,
|
||||||
|
action=add`) → `200 { ok: true }`, which writes both files through the
|
||||||
|
same privileged write path as the other tabs. Each row's `remove` button
|
||||||
|
opens a themed confirm dialog, then posts the same endpoint with
|
||||||
|
`action=remove`, deleting both local files — nothing changes on the
|
||||||
|
remote forge. The token is never echoed back in either direction.
|
||||||
|
|
||||||
|
A per-forge `hive-forge --forge <label>` CLI selector (to make `hive-forge`
|
||||||
|
target one of these accounts instead of the internal forge) is a
|
||||||
|
deliberate non-goal of this tab — tracked separately.
|
||||||
|
|
||||||
## P3RM1SS10NS tab
|
## P3RM1SS10NS tab
|
||||||
|
|
||||||
Per-agent permission configuration. Two sections, each rendered as a
|
Per-agent permission configuration. Two sections, each rendered as a
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@
|
||||||
aria-controls="cred-pane-github" data-tab="github">
|
aria-controls="cred-pane-github" data-tab="github">
|
||||||
<span class="cred-tab-label">GITHUB</span>
|
<span class="cred-tab-label">GITHUB</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="hive-tab" id="cred-tab-forges" href="#forges" role="tab"
|
||||||
|
aria-controls="cred-pane-forges" data-tab="forges">
|
||||||
|
<span class="cred-tab-label">FORGES</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -125,6 +129,38 @@
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
||||||
|
Entirely dashboard-provisioned, no host-side nix config — same
|
||||||
|
shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
|
||||||
|
The operator creates a token on the external forge themselves
|
||||||
|
(however that forge lets them) and pastes label + URL + token
|
||||||
|
below. No remote account minting/revoking — purely local. -->
|
||||||
|
<section class="cred-pane" id="cred-pane-forges" data-tab-pane="forges"
|
||||||
|
role="tabpanel" aria-labelledby="cred-tab-forges" hidden>
|
||||||
|
<p class="meta">store a <strong>label + base URL + access token</strong> for an external Forgejo/Gitea/Codeberg-compatible forge, per agent. no account is created on the remote forge — create the token there yourself first. the token is never displayed back on this page.</p>
|
||||||
|
|
||||||
|
<h3>◇ provisioned forges</h3>
|
||||||
|
<div id="ef-list" class="ef-list"><p class="meta">select an agent to see its forge accounts.</p></div>
|
||||||
|
|
||||||
|
<h3>◇ add forge account</h3>
|
||||||
|
<form id="ef-form" class="ma-form" autocomplete="off">
|
||||||
|
<label class="ma-field">
|
||||||
|
<span>label</span>
|
||||||
|
<input type="text" name="label" placeholder="e.g. codeberg" required>
|
||||||
|
</label>
|
||||||
|
<label class="ma-field">
|
||||||
|
<span>base url</span>
|
||||||
|
<input type="text" name="base_url" placeholder="https://codeberg.org" required>
|
||||||
|
</label>
|
||||||
|
<label class="ma-field">
|
||||||
|
<span>access token</span>
|
||||||
|
<input type="password" name="token" autocomplete="off" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-spawn">store account</button>
|
||||||
|
<p id="ef-result" class="ma-result" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/static/credentials.js" defer></script>
|
<script type="module" src="/static/credentials.js" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,18 @@
|
||||||
// {ok:true}; same error_response shape as matrix-account-login).
|
// {ok:true}; same error_response shape as matrix-account-login).
|
||||||
// No account name / homeserver / login mode, and no
|
// No account name / homeserver / login mode, and no
|
||||||
// live/heartbeat concept for a static PAT — just present/absent.
|
// live/heartbeat concept for a static PAT — just present/absent.
|
||||||
|
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
||||||
|
// host-side config): GET /api/extra-forges?agent= lists the
|
||||||
|
// agent's stored {label, base_url} pairs, POST
|
||||||
|
// /api/extra-forge-account (form agent/label/base_url/token/
|
||||||
|
// action=add|remove) stores or removes one. No remote account
|
||||||
|
// creation — the operator makes the token on the external forge
|
||||||
|
// themselves and pastes it in, same trust model as GITHUB.
|
||||||
// Per-tab detail comments live next to their section below.
|
// Per-tab detail comments live next to their section below.
|
||||||
|
|
||||||
import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
|
import { $, el, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
import { themedConfirm, themedToast } from './modal.js';
|
||||||
|
|
||||||
let agents = [];
|
let agents = [];
|
||||||
// agent name → container running (bool), from /api/state. Cross-referenced by
|
// agent name → container running (bool), from /api/state. Cross-referenced by
|
||||||
|
|
@ -342,11 +350,149 @@ async function submitGithub(e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── FORGES tab ─────────────────────────────────────────────────────────
|
||||||
|
// Entirely dashboard-provisioned, no host-side nix config: per-agent list
|
||||||
|
// (GET /api/extra-forges?agent=, derived from the agent's own
|
||||||
|
// forge-<label>-token files) + an add form (POST /api/extra-forge-account,
|
||||||
|
// form label/base_url/token, action=add) and a remove button per row
|
||||||
|
// (same POST, action=remove). No remote account creation — purely local
|
||||||
|
// bookkeeping for a token the operator already created on the external
|
||||||
|
// forge themselves.
|
||||||
|
|
||||||
|
async function loadForgeAccounts(agent) {
|
||||||
|
const list = $('ef-list');
|
||||||
|
if (!agent) {
|
||||||
|
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
||||||
|
let forges;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/extra-forges?agent=' + encodeURIComponent(agent));
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
forges = (await resp.json()).forges || [];
|
||||||
|
} catch (err) {
|
||||||
|
list.replaceChildren(el('p', { class: 'err' },
|
||||||
|
'could not load forge accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.replaceChildren();
|
||||||
|
if (!forges.length) {
|
||||||
|
list.replaceChildren(el('p', { class: 'meta' }, 'no forge accounts stored for this agent.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ul = el('ul', { class: 'ma-accounts' });
|
||||||
|
for (const forge of forges) {
|
||||||
|
const btn = el('button', { class: 'btn', type: 'button' }, 'remove');
|
||||||
|
btn.addEventListener('click', () => onForgeRemoveClick(agent, forge, btn));
|
||||||
|
ul.append(el('li', { class: 'ma-account' },
|
||||||
|
el('span', { class: 'ma-dot ok' }),
|
||||||
|
el('span', { class: 'ma-name' }, forge.label),
|
||||||
|
el('span', { class: 'ma-hs' }, forge.base_url || '—'),
|
||||||
|
el('span', { class: 'ma-status ok' }, 'token stored ✓'),
|
||||||
|
btn,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
list.append(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onForgeRemoveClick(agent, forge, btn) {
|
||||||
|
const r = await themedConfirm({
|
||||||
|
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: '⊘ remove',
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'removing…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/extra-forge-account', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ agent, label: forge.label, action: 'remove' }),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
loadForgeAccounts(agent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = await readErrorBody(resp);
|
||||||
|
btn.textContent = orig;
|
||||||
|
btn.disabled = false;
|
||||||
|
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
|
||||||
|
} catch (err) {
|
||||||
|
btn.textContent = orig;
|
||||||
|
btn.disabled = false;
|
||||||
|
themedToast('✗ request failed: ' + String(err), { type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForgeAccount(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formEl = e.target;
|
||||||
|
const out = $('ef-result');
|
||||||
|
out.className = 'ma-result';
|
||||||
|
out.textContent = '';
|
||||||
|
|
||||||
|
const agent = $('ma-agent').value;
|
||||||
|
if (!agent) {
|
||||||
|
out.className = 'ma-result err';
|
||||||
|
out.textContent = 'select an agent first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData(formEl);
|
||||||
|
fd.set('agent', agent);
|
||||||
|
fd.set('action', 'add');
|
||||||
|
|
||||||
|
const btn = formEl.querySelector('button[type="submit"]');
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'storing…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/extra-forge-account', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fd),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
let body = {};
|
||||||
|
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
|
||||||
|
if (body.ok) {
|
||||||
|
out.className = 'ma-result ok';
|
||||||
|
out.textContent = '✓ forge account stored.';
|
||||||
|
clearSecrets(formEl);
|
||||||
|
loadForgeAccounts(agent);
|
||||||
|
} else {
|
||||||
|
out.className = 'ma-result err';
|
||||||
|
out.textContent = '✗ store failed (unexpected response).';
|
||||||
|
clearSecrets(formEl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const msg = await readErrorBody(resp);
|
||||||
|
out.className = 'ma-result err';
|
||||||
|
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
||||||
|
clearSecrets(formEl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
out.className = 'ma-result err';
|
||||||
|
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── init ─────────────────────────────────────────────────────────────
|
// ─── init ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function onAgentChange(agent) {
|
async function onAgentChange(agent) {
|
||||||
loadAccounts(agent);
|
loadAccounts(agent);
|
||||||
loadGithubStatus(agent);
|
loadGithubStatus(agent);
|
||||||
|
loadForgeAccounts(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
|
@ -358,6 +504,7 @@ async function init() {
|
||||||
toggleModeFields();
|
toggleModeFields();
|
||||||
$('ma-form').addEventListener('submit', submitLogin);
|
$('ma-form').addEventListener('submit', submitLogin);
|
||||||
$('gh-form').addEventListener('submit', submitGithub);
|
$('gh-form').addEventListener('submit', submitGithub);
|
||||||
|
$('ef-form').addEventListener('submit', submitForgeAccount);
|
||||||
|
|
||||||
createTabStrip(document.getElementById('cred-tabbar'), {
|
createTabStrip(document.getElementById('cred-tabbar'), {
|
||||||
defaultId: 'matrix',
|
defaultId: 'matrix',
|
||||||
|
|
|
||||||
205
hive-c0re/src/dashboard/extra_forges.rs
Normal file
205
hive-c0re/src/dashboard/extra_forges.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
//! Dashboard-driven external (non-internal) Forgejo/Gitea/Codeberg-compatible
|
||||||
|
//! forge accounts, per agent. Entirely dashboard-provisioned — there is no
|
||||||
|
//! host-side nix config for these (see `nix/modules/hive-forge.nix`'s
|
||||||
|
//! removed `extraForges` option). The operator manually creates a token on
|
||||||
|
//! the external forge themselves (however that forge lets them: PAT UI, a
|
||||||
|
//! teammate with admin, whatever) and pastes a label + base URL + token into
|
||||||
|
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
|
||||||
|
//! (`post_github_account`) plus the homeserver field from the matrix extra-
|
||||||
|
//! account flow (`post_matrix_account_login`).
|
||||||
|
//!
|
||||||
|
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
|
||||||
|
//! this module only ever touches the *local* agent state dir. hive-c0re
|
||||||
|
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
|
||||||
|
//! URL to a `<state>/forge-<label>.json` sidecar (not secret, but kept next
|
||||||
|
//! to the token so both survive together) via hive-priv. Listing derives the
|
||||||
|
//! configured set from those files, mirroring `matrix_accounts.rs`'s
|
||||||
|
//! filename-scan approach — there is no separate "catalog" now that there's
|
||||||
|
//! no nix config to enumerate.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use axum::extract::{Form, Query};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::error_response;
|
||||||
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
|
/// Plain-identifier check matching hive-priv's `validate_name_chars`
|
||||||
|
/// (lowercase ascii + digits + hyphens) — same guard used by
|
||||||
|
/// `matrix_accounts::is_plain_ident`. Duplicated locally (private, not
|
||||||
|
/// worth a shared-util churn for one predicate) rather than exported from
|
||||||
|
/// that module, since both call sites are dashboard-only.
|
||||||
|
fn is_plain_ident(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ForgeSidecar {
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the base URL an agent stashed for `label` from its
|
||||||
|
/// `forge-<label>.json` sidecar. `None` if the sidecar is missing or
|
||||||
|
/// unparseable (e.g. a token file left over from a partial/older write).
|
||||||
|
fn read_base_url(dir: &Path, label: &str) -> Option<String> {
|
||||||
|
let s = std::fs::read_to_string(dir.join(format!("forge-{label}.json"))).ok()?;
|
||||||
|
serde_json::from_str::<ForgeSidecar>(&s)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.base_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ExtraForgeAccount {
|
||||||
|
label: String,
|
||||||
|
base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ExtraForgesResponse {
|
||||||
|
forges: Vec<ExtraForgeAccount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct ExtraForgesQuery {
|
||||||
|
agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/extra-forges?agent=<name>` — list the external forge accounts
|
||||||
|
/// currently provisioned for `agent`, derived from every `forge-<label>-
|
||||||
|
/// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan
|
||||||
|
/// listing). `base_url` is backfilled from the matching `forge-<label>.json`
|
||||||
|
/// sidecar when present. Never returns a token.
|
||||||
|
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
|
||||||
|
let agent = q.agent.trim();
|
||||||
|
if !is_plain_ident(agent) {
|
||||||
|
return error_response(&format!("extra-forges: invalid agent {agent:?}"));
|
||||||
|
}
|
||||||
|
let dir = Coordinator::agent_notes_dir(agent);
|
||||||
|
let mut forges = Vec::new();
|
||||||
|
match std::fs::read_dir(&dir) {
|
||||||
|
Ok(entries) => {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !entry.file_type().is_ok_and(|ft| ft.is_file()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fname = entry.file_name();
|
||||||
|
let Some(fname) = fname.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// `forge-token` (no suffix) is the mandatory internal forge —
|
||||||
|
// not one of these dashboard-provisioned extra accounts.
|
||||||
|
let Some(label) = fname
|
||||||
|
.strip_prefix("forge-")
|
||||||
|
.and_then(|s| s.strip_suffix("-token"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if label.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
forges.push(ExtraForgeAccount {
|
||||||
|
base_url: read_base_url(&dir, label),
|
||||||
|
label: label.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => {
|
||||||
|
return error_response(&format!("extra-forges: read {}: {e}", dir.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forges.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
axum::Json(ExtraForgesResponse { forges }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Form body for `POST /api/extra-forge-account` (urlencoded, the
|
||||||
|
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
|
||||||
|
/// `token`) or `"remove"`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct ExtraForgeAccountForm {
|
||||||
|
agent: String,
|
||||||
|
label: String,
|
||||||
|
action: String,
|
||||||
|
base_url: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ExtraForgeAccountResult {
|
||||||
|
ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/extra-forge-account` — add persists the operator-pasted
|
||||||
|
/// label/base-URL/token to the agent's state dir via hive-priv; remove
|
||||||
|
/// deletes both files. Purely local — no remote account creation or
|
||||||
|
/// revocation, there is no admin access assumed on the external forge.
|
||||||
|
/// Operator-authenticated (dashboard). Never echoes the token back.
|
||||||
|
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
|
||||||
|
let agent = f.agent.trim();
|
||||||
|
let label = f.label.trim();
|
||||||
|
if !is_plain_ident(agent) {
|
||||||
|
return error_response(&format!("extra-forge-account: invalid agent {agent:?}"));
|
||||||
|
}
|
||||||
|
if !is_plain_ident(label) {
|
||||||
|
return error_response(&format!("extra-forge-account: invalid label {label:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
match f.action.as_str() {
|
||||||
|
"add" => {
|
||||||
|
let base_url = f.base_url.as_deref().unwrap_or_default().trim();
|
||||||
|
let base_url = base_url.trim_end_matches('/');
|
||||||
|
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
|
||||||
|
return error_response(&format!(
|
||||||
|
"extra-forge-account: base_url must be an http(s) URL, got {base_url:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(token) = f.token.as_deref().filter(|t| !t.is_empty()) else {
|
||||||
|
return error_response("extra-forge-account: token is required");
|
||||||
|
};
|
||||||
|
if let Err(e) =
|
||||||
|
crate::priv_client::write_agent_extra_forge_account(agent, label, base_url, token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return error_response(&format!(
|
||||||
|
"extra-forge-account: write account failed: {e:#}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::info!(%agent, %label, "extra-forge-account: provisioned");
|
||||||
|
}
|
||||||
|
"remove" => {
|
||||||
|
if let Err(e) = crate::priv_client::delete_agent_extra_forge_account(agent, label).await
|
||||||
|
{
|
||||||
|
return error_response(&format!(
|
||||||
|
"extra-forge-account: delete account failed: {e:#}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tracing::info!(%agent, %label, "extra-forge-account: removed");
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return error_response(&format!(
|
||||||
|
"extra-forge-account: unknown action {other:?} (want add|remove)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
axum::Json(ExtraForgeAccountResult { ok: true }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_plain_ident;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_plain_ident_matches_validate_name_chars() {
|
||||||
|
assert!(is_plain_ident("codeberg"));
|
||||||
|
assert!(is_plain_ident("my-forge-1"));
|
||||||
|
assert!(!is_plain_ident(""));
|
||||||
|
assert!(!is_plain_ident("MyForge"));
|
||||||
|
assert!(!is_plain_ident("my_forge"));
|
||||||
|
assert!(!is_plain_ident("../escape"));
|
||||||
|
assert!(!is_plain_ident("a/b"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::lifecycle;
|
||||||
|
|
||||||
mod approvals;
|
mod approvals;
|
||||||
mod build_logs;
|
mod build_logs;
|
||||||
|
mod extra_forges;
|
||||||
mod infra_containers;
|
mod infra_containers;
|
||||||
mod journal;
|
mod journal;
|
||||||
mod lifecycle_ops;
|
mod lifecycle_ops;
|
||||||
|
|
@ -88,6 +89,11 @@ pub async fn serve(
|
||||||
"/api/matrix-accounts",
|
"/api/matrix-accounts",
|
||||||
get(matrix_accounts::get_matrix_accounts),
|
get(matrix_accounts::get_matrix_accounts),
|
||||||
)
|
)
|
||||||
|
.route("/api/extra-forges", get(extra_forges::get_extra_forges))
|
||||||
|
.route(
|
||||||
|
"/api/extra-forge-account",
|
||||||
|
post(extra_forges::post_extra_forge_account),
|
||||||
|
)
|
||||||
.route("/api/reminders", get(reminders::api_reminders))
|
.route("/api/reminders", get(reminders::api_reminders))
|
||||||
.route("/api/operator-inbox", get(misc_api::api_operator_inbox))
|
.route("/api/operator-inbox", get(misc_api::api_operator_inbox))
|
||||||
.route("/api/stats-hive", get(misc_api::api_stats_hive))
|
.route("/api/stats-hive", get(misc_api::api_stats_hive))
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
||||||
// build.
|
// build.
|
||||||
/// Per-agent token scopes (broad-but-not-admin). See
|
/// Per-agent token scopes (broad-but-not-admin). See
|
||||||
/// `docs/forge.md::Token scopes` for the per-scope rationale.
|
/// `docs/forge.md::Token scopes` for the per-scope rationale.
|
||||||
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
/// `pub(super)` — also reused by `extra.rs`'s external-forge
|
||||||
|
/// provisioning so a granted agent gets the same scope set on an
|
||||||
|
/// extra forge as on the internal one.
|
||||||
|
pub(super) const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||||
|
|
||||||
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
||||||
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,37 @@ pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<(
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a per-agent account for a dashboard-declared external forge —
|
||||||
|
/// label + base URL + token — to `<state>/forge-<label>-token` +
|
||||||
|
/// `<state>/forge-<label>.json` via hive-priv. Entirely dashboard-
|
||||||
|
/// provisioned, no host-side nix config; `label` is validated root-side as
|
||||||
|
/// a plain identifier before it reaches the filename.
|
||||||
|
pub async fn write_agent_extra_forge_account(
|
||||||
|
agent_name: &str,
|
||||||
|
label: &str,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
ok(call(&PrivRequest::WriteAgentExtraForgeAccount {
|
||||||
|
agent_name: agent_name.to_owned(),
|
||||||
|
label: label.to_owned(),
|
||||||
|
base_url: base_url.to_owned(),
|
||||||
|
token: token.to_owned(),
|
||||||
|
})
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a previously-added extra-forge account — the counterpart of
|
||||||
|
/// [`write_agent_extra_forge_account`]. Idempotent: missing files are not
|
||||||
|
/// an error.
|
||||||
|
pub async fn delete_agent_extra_forge_account(agent_name: &str, label: &str) -> Result<()> {
|
||||||
|
ok(call(&PrivRequest::DeleteAgentExtraForgeAccount {
|
||||||
|
agent_name: agent_name.to_owned(),
|
||||||
|
label: label.to_owned(),
|
||||||
|
})
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
||||||
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
|
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
|
||||||
/// Non-fatal: callers should handle errors gracefully — if the container is
|
/// Non-fatal: callers should handle errors gracefully — if the container is
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,36 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
||||||
write_agent_state_file(agent_name, "github-token", &format!("{token}\n"))
|
write_agent_state_file(agent_name, "github-token", &format!("{token}\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PrivRequest::WriteAgentExtraForgeAccount {
|
||||||
|
ref agent_name,
|
||||||
|
ref label,
|
||||||
|
ref base_url,
|
||||||
|
ref token,
|
||||||
|
} => {
|
||||||
|
validate_agent_name(agent_name)?;
|
||||||
|
validate_name_chars(label)?;
|
||||||
|
let res = write_agent_state_file(
|
||||||
|
agent_name,
|
||||||
|
&format!("forge-{label}-token"),
|
||||||
|
&format!("{token}\n"),
|
||||||
|
)?;
|
||||||
|
// Sidecar carries the base URL — there's no host-side nix config
|
||||||
|
// for extra forges, so this is the only place it's persisted.
|
||||||
|
let meta = serde_json::json!({ "base_url": base_url }).to_string();
|
||||||
|
write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
PrivRequest::DeleteAgentExtraForgeAccount {
|
||||||
|
ref agent_name,
|
||||||
|
ref label,
|
||||||
|
} => {
|
||||||
|
validate_agent_name(agent_name)?;
|
||||||
|
validate_name_chars(label)?;
|
||||||
|
delete_agent_state_file(agent_name, &format!("forge-{label}-token"))?;
|
||||||
|
delete_agent_state_file(agent_name, &format!("forge-{label}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
PrivRequest::RestartMatrixDaemon { ref agent_name } => {
|
PrivRequest::RestartMatrixDaemon { ref agent_name } => {
|
||||||
restart_matrix_daemon(agent_name).await
|
restart_matrix_daemon(agent_name).await
|
||||||
}
|
}
|
||||||
|
|
@ -636,6 +666,30 @@ fn write_agent_state_file(
|
||||||
Ok((String::new(), String::new()))
|
Ok((String::new(), String::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove `AGENT_STATE_ROOT/<agent_name>/state/<filename>` if present.
|
||||||
|
/// Idempotent revoke counterpart to [`write_agent_state_file`] — a
|
||||||
|
/// missing file is success, not an error. `filename` must be a single
|
||||||
|
/// plain component (no `/`, `.`, `..`); callers pass a pre-validated
|
||||||
|
/// label into a fixed `forge-<label>-token` shape, same as the write
|
||||||
|
/// side.
|
||||||
|
fn delete_agent_state_file(agent_name: &str, filename: &str) -> Result<(String, String)> {
|
||||||
|
if filename.is_empty() || filename == "." || filename == ".." || filename.contains('/') {
|
||||||
|
bail!("delete_agent_state_file: refusing non-plain filename {filename:?}");
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(AGENT_STATE_ROOT)
|
||||||
|
.join(agent_name)
|
||||||
|
.join("state")
|
||||||
|
.join(filename);
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(agent = %agent_name, file = %filename, "removed agent state file");
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(e).with_context(|| format!("remove {}", path.display())),
|
||||||
|
}
|
||||||
|
Ok((String::new(), String::new()))
|
||||||
|
}
|
||||||
|
|
||||||
/// btrfs superblock magic, as reported by `statfs(2)`'s `f_type`.
|
/// btrfs superblock magic, as reported by `statfs(2)`'s `f_type`.
|
||||||
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;
|
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,45 @@ pub enum PrivRequest {
|
||||||
token: String,
|
token: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Write a per-agent account for an external, dashboard-declared forge:
|
||||||
|
/// the access token to
|
||||||
|
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
|
||||||
|
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
|
||||||
|
/// the base URL survives without any host-side nix config — the whole
|
||||||
|
/// account (label + URL + token) is operator-entered on the dashboard,
|
||||||
|
/// same shape as `WriteAgentMatrixToken`'s homeserver sidecar.
|
||||||
|
///
|
||||||
|
/// `label` MUST be validated as a plain identifier (same rule as the
|
||||||
|
/// matrix `account` suffix) before it goes into the filename — a
|
||||||
|
/// crafted label could otherwise traverse out of the state dir. Same
|
||||||
|
/// write semantics as `WriteAgentForgeToken` — validates `agent_name`,
|
||||||
|
/// creates the state dir if absent, writes both files 0600, chowns to
|
||||||
|
/// the agent.
|
||||||
|
WriteAgentExtraForgeAccount {
|
||||||
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
|
agent_name: String,
|
||||||
|
/// Dashboard-chosen label for this external forge. Validated as a
|
||||||
|
/// plain identifier before use.
|
||||||
|
label: String,
|
||||||
|
/// Base HTTP(S) URL of the external forge, operator-entered on the
|
||||||
|
/// dashboard (no host-side config).
|
||||||
|
base_url: String,
|
||||||
|
/// Token value. hive-priv appends a trailing newline before writing.
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a previously-written `forge-<label>-token` + `forge-<label>.
|
||||||
|
/// json` from an agent's state dir — the revoke half of
|
||||||
|
/// `WriteAgentExtraForgeAccount`. Missing files are not an error
|
||||||
|
/// (idempotent revoke).
|
||||||
|
DeleteAgentExtraForgeAccount {
|
||||||
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
|
agent_name: String,
|
||||||
|
/// The forge label to revoke. Validated as a plain identifier
|
||||||
|
/// before use.
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
|
||||||
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
||||||
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
|
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
|
||||||
/// Used by hive-c0re to kick the daemon after a successful token write
|
/// Used by hive-c0re to kick the daemon after a successful token write
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,15 @@ in
|
||||||
# at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
# at `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||||||
# restart. See `docs/gateway.md::hive-forge container shape`.
|
# restart. See `docs/gateway.md::hive-forge container shape`.
|
||||||
|
|
||||||
|
# External Forgejo/Gitea/Codeberg-compatible forges (beyond the mandatory
|
||||||
|
# internal one) are entirely dashboard-provisioned — no nix config here.
|
||||||
|
# An operator manually creates a token on the external forge (however
|
||||||
|
# that forge lets them) and pastes name + base URL + token into the
|
||||||
|
# dashboard's FORGES tab; hive-c0re just persists it to
|
||||||
|
# `<state>/forge-<label>-token` + a `<state>/forge-<label>.json` sidecar
|
||||||
|
# (base URL), the same shape as the GitHub PAT / matrix extra-account
|
||||||
|
# flows. See `hive-c0re/src/dashboard/extra_forges.rs`.
|
||||||
|
|
||||||
# The internal forge is mandatory — it's the canonical store for the
|
# The internal forge is mandatory — it's the canonical store for the
|
||||||
# meta flake + every agent's config repo (and the `internal/*` repos),
|
# meta flake + every agent's config repo (and the `internal/*` repos),
|
||||||
# so there is no enable/disable toggle. It deploys whenever hyperhive
|
# so there is no enable/disable toggle. It deploys whenever hyperhive
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue