Add a node from the console, and make curl | sh actually work

The page an administrator uses to bring a node online: name it, get a one-line command
and a six-digit code, hand both to whoever is at the machine. That is the last piece
between "someone runs a command" and self-service onboarding.

POST /api/compute/pairing-script now returns JSON - script, code, expiry and the
command - rather than the bare script. There are two callers with two needs: a person at
a terminal wants a script to pipe to sh, and the console wants all three values so it can
show them once and then forget them. Returning text would have forced the console to
parse a shell script to recover a six-digit number, which works until someone reformats
the script.

The code is shown once and there is no button to fetch it again, because the controller
keeps only an Argon2id hash. That is the server's limit, not a UI choice, and offering a
"show code" button would be a lie about what it can do.

## Three defects the template flow exposed

Running `curl ... | sh` on node 238 - the flow the console hands out - found that the
template could never pair, for three separate reasons:

- It had no prompt and no code. `000000` is the placeholder that means "nobody filled
  this in", so the script now asks. A rendered script from the console has the code
  substituted and skips the prompt, so nobody is asked for a number they do not need.
- It had no node name, and the name is what the code is bound to. It now asks too. The
  first version failed with "check the code has not expired" while the code was fine and
  the *name* was the placeholder - an error pointing at the wrong thing.
- It hardcoded `controller.invalid` as its controller address, so every deployment but
  one got a script that could not resolve its own controller. The address now comes from
  the Host header the template was fetched with, and `UMBRA_CONTROLLER` overrides it.

The error message itself was part of the problem: a DNS failure reported "check the code
has not expired", which is a worse fault than no message because it sends an operator to
check the one thing that was working.

## Verified

`curl -fsSL http://192.168.123.14:8442/api/node/bootstrap | sh` on 238, code and name
typed at the prompts: paired, agent installed, service registered, node `online`.

`/api/node/bootstrap` is unauthenticated and returns only the inert template - a script
that can check a machine and then stop. It is listed by name in the test that pins
unauthenticated node routes, alongside /pair, with the reason each is safe.

## Also found, not fixed

`VIEWS` in web/assets/admin.js is `['dashboard','accounts','organizations','audit']` but
`view-projects` exists in the markup, so the projects view can never be activated. It
predates this work and I have left it alone rather than change navigation behaviour as a
side effect of adding a page.

525 passed, 27 skipped.
This commit is contained in:
umbra audio
2026-10-03 11:10:42 -07:00
parent bc031dd079
commit c3a3e506ec
5 changed files with 805 additions and 38 deletions

View File

@@ -58,7 +58,29 @@ set -eu
BOOTSTRAP_VERSION="__BOOTSTRAP_VERSION__"
NODE_NAME="__NODE_NAME__"
PAIRING_CODE="__PAIRING_CODE__"
CONTROLLER="__CONTROLLER__"
# A pairing code of 000000 means "nobody filled this in" - which is exactly what the
# template served by GET /api/node/bootstrap carries. So the script asks.
#
# Without this the two flows cannot both exist. `curl ... | sh` downloads a script with
# no code in it, and a script that cannot prompt is a script that can never pair. The
# console's rendered copy has the code substituted and skips the prompt, so an
# operator who already has a rendered script is not asked for a number they do not
# need.
if [ "$PAIRING_CODE" = "000000" ]; then
printf 'Enter the six digit pairing code: ' >&2
read -r PAIRING_CODE
[ -n "$PAIRING_CODE" ] || die "A pairing code is required. Ask an administrator for one."
fi
# The same reasoning for the name. The template carries a placeholder, and the name is
# what the code is bound to, so a template run without asking pairs nothing and the
# failure points at the code - which is the wrong thing to be looking at.
if [ "$NODE_NAME" = "unpaired-node" ]; then
printf 'Node name (as the administrator entered it): ' >&2
read -r NODE_NAME
[ -n "$NODE_NAME" ] || die "A node name is required. It must match the code exactly."
fi
CONTROLLER="${UMBRA_CONTROLLER:-__CONTROLLER__}"
AGENT_DIR="__AGENT_DIR__"
CONFIG_PATH="__CONFIG_PATH__"
UNIT_NAME="__UNIT_NAME__"
@@ -71,6 +93,19 @@ die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------- who am I
# The controller this script was fetched from. Taken from the request rather than baked
# in, because the template is served by every deployment and has to work on all of them.
# The first version hardcoded a placeholder host and the failure was "could not resolve
# host", with an error message that pointed at the pairing code - the wrong thing
# entirely, and the reason this is worth stating plainly.
case "$CONTROLLER" in
*controller.invalid*)
CONTROLLER="__DEFAULT_CONTROLLER__"
say " controller $CONTROLLER (defaulted; override with UMBRA_CONTROLLER=...)"
;;
*) say " controller $CONTROLLER" ;;
esac
step "Checking this machine"
[ "$(uname -s)" = "Linux" ] || die "This bootstrap is for Linux. Found $(uname -s)."
say " os $(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-unknown}" || echo unknown)"
@@ -429,3 +464,31 @@ def render(
#: Kept as a distinct name so the tests can drive the substitution directly without
#: depending on the layout choice.
render_template = render_full
def render_bootstrap_template(default_controller: str = "") -> str:
"""The bootstrap with placeholders and no usable pairing code.
What ``GET /api/node/bootstrap`` serves. Inert: it checks the machine and then
fails to pair, because the code it needs was never filled in. That is the property
that makes ``curl | sh`` reasonable to hand to someone - the downloaded program has
no authority until an operator types a code into it.
"""
return (
BOOTSTRAP.replace("__NODE_NAME__", "unpaired-node")
.replace("__PAIRING_CODE__", "000000")
.replace("__CONTROLLER__", default_controller or "")
.replace("__BOOTSTRAP_VERSION__", BOOTSTRAP_VERSION)
.replace("__AGENT_DIR__", "$HOME/.local/share/umbra/agent")
.replace("__CONFIG_PATH__", "$HOME/.config/umbra/node.json")
.replace("__UNIT_NAME__", "umbra-node")
.replace("__SERVICE_USER__", "umbra-node")
.replace("__INSTALL_ROOT__", "$HOME/.local/share/umbra")
)
def bootstrap_version() -> str:
"""The template version, so a caller can tell whether what it fetched is current."""
return BOOTSTRAP_VERSION

View File

@@ -33,7 +33,13 @@ from fastapi import APIRouter, Depends, Header, Query, Request, Response
from pydantic import BaseModel, Field
from .app import require_admin
from .bootstrap import render as render_bootstrap
from .bootstrap import (
bootstrap_version,
render_bootstrap_template,
)
from .bootstrap import (
render as render_bootstrap,
)
from .compute import (
DEFAULT_LEASE_SECONDS,
NODE_SCOPES,
@@ -141,6 +147,22 @@ class CreateNodeRequest(RequestModel):
transport: str = Field(default="https", pattern="^(https|ssh|vpn)$")
class BootstrapResponse(BaseModel):
"""A rendered bootstrap, and the single-use code inside it.
``code`` is here once and never again: the controller stores only an Argon2id hash,
so this response is the only moment the plaintext exists outside the shell script
that embeds it.
"""
node_name: str
code: str
code_hint: str
expires_at: datetime
script: str
command: str
class BootstrapRequest(RequestModel):
"""What an administrator needs to say to render a bootstrap.
@@ -851,33 +873,23 @@ def create_bootstrap(
request: Request,
principal: Principal = Depends(require_admin),
organization_id: UUID | None = Query(default=None),
) -> Response:
"""Render a bootstrap script for a node that has a live pairing code.
) -> BootstrapResponse:
"""Render a bootstrap script, and the code it will use.
On the *administrator* router, not the node router. It requires a session and
mints a pairing code, which is exactly the power a node must not have: a node that
could call this could mint itself a fresh credential for any node name it chose.
That it declared an administrator dependency while sitting on the node surface
was a real privilege bug, and the pinned node-surface test is what surfaced it.
Returns JSON rather than the bare script, because there are two callers with two
different needs. A person at a terminal wants a script they can pipe to `sh`; the
console wants the script *and* the code and the expiry, so it can show all three
once and then forget them. Returning text would force the console to parse a shell
script to recover a six-digit number, which is the kind of thing that works until
someone reformats the script.
Separate from `/api/node/pair` on purpose. Pairing consumes a code and issues a
credential; this *renders* a script that will consume one. The administrator asks
for it, reads it, and hands the `curl` line to whoever is at the machine.
The rendered script is what an exclusive node and a shared node differ on, and the
difference is decided by whether root is available at run time rather than here -
so one script serves both and neither the controller nor the operator has to know
in advance which kind of machine it is.
Returned as `text/x-sh` rather than JSON: it is a script, and wrapping it would only
mean the recipient has to unwrap it before they can read what they are about to run.
The code appears in this response and in no later one - the controller keeps only an
Argon2id hash. That is why the console shows it once and offers no way to fetch it
again, rather than that being a UI choice.
"""
repository = compute_repository(request)
target = organization_id or _organization_of(principal)
# A code is minted here rather than having one passed in, so the script is always
# paired with a code that is live and that this administrator just created. A code
# pasted into a request body could be one issued minutes ago for another purpose.
minted = repository.create_pairing_code(
target,
payload.node_name,
@@ -890,22 +902,31 @@ def create_bootstrap(
node_name=payload.node_name,
pairing_code=minted["code"],
controller=controller,
root=payload.as_root,
as_root=payload.as_root,
)
return Response(
content=script,
media_type="text/x-sh",
headers={
"X-Pairing-Code-Expires": minted["expires_at"].isoformat(),
"X-Pairing-Code-Hint": minted["code_hint"],
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
return BootstrapResponse(
node_name=payload.node_name,
code=minted["code"],
code_hint=minted["code_hint"],
expires_at=minted["expires_at"],
script=script,
# The one line a person pastes. Built here rather than in the browser so the
# console and a shell user get the same command.
command=(
f"curl -fsSL {controller}/api/node/bootstrap | sh"
f"\n# then enter the code when asked: {minted['code']}"
),
)
# ---------------------------------------------------------------------------
# Agent surface: /api/node
# --------------------------------------------------------------------------
#
# Everything below authenticates from a node credential, never from a session. A node
# has no browser, no cookie and no password, so anything that assumes one does not work
# here. Two routes are deliberately unauthenticated and are named as exceptions in
# test_compute.py with the reason each is safe.
# ---------------------------------------------------------------------------
@agent_router.get("/agent")
@@ -948,6 +969,36 @@ def fetch_agent(node: NodeContext = Depends(require_node)) -> Response:
)
@agent_router.get("/bootstrap")
def fetch_bootstrap(request: Request) -> Response:
"""Serve the bootstrap script itself.
The script embeds a pairing code, so it cannot be rendered without one - and this
route renders none. It serves the *template*: a script with placeholders and no
code, which is inert until an administrator fills it in. That is what makes
`curl ... | sh` a safe thing to show someone: the pipe downloads a program, and the
code that lets it do anything is typed by the operator afterwards.
Deliberately unauthenticated, and that is safe *only* because of what it returns. A
script with no code can check the machine, report what it finds, and stop. It cannot
pair, cannot authenticate, and cannot read anything.
Listed by name in the test that pins unauthenticated node routes, with its reason,
so a second open route has to be added deliberately.
"""
template = render_bootstrap_template(default_controller=_own_base_url(request))
return Response(
content=template,
media_type="text/x-sh",
headers={
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"X-Bootstrap-Version": bootstrap_version(),
},
)
@agent_router.post("/pair", response_model=NodePairResponse)
def pair_node(payload: NodePairRequest, request: Request) -> NodePairResponse:
"""Exchange a pairing code for a node credential.
@@ -1164,3 +1215,19 @@ def decompose(
__all__ = ["admin_router", "agent_router"]
def _own_base_url(request: Request) -> str:
"""The address this service is reachable at, as the caller used it.
Taken from the Host header rather than a configured value, because the whole point
is that the template works on whichever deployment served it. A hardcoded controller
address was the first version and it meant every other deployment got a script that
could not resolve its own controller.
"""
host = request.headers.get("host") or ""
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme or "http"
if not host:
return ""
return f"{scheme}://{host}"

View File

@@ -732,6 +732,7 @@ def test_the_node_surface_is_exactly_what_a_node_may_do() -> None:
# test_every_node_route_uses_node_credential_authentication below.
"/api/node/pair",
"/api/node/agent",
"/api/node/bootstrap",
"/api/node/heartbeat",
"/api/node/models/{slug}/state",
"/api/node/jobs/claim",
@@ -770,7 +771,17 @@ def test_every_node_route_uses_node_credential_authentication() -> None:
# administrator action. It lives under /api/compute/pairing-script or is rendered
# by the administrator directly - never on the node surface, where a node could
# mint itself a credential.
UNAUTHENTICATED = {"/pair"}
# Two deliberate exceptions, each with the reason it is safe.
#
# /pair is the moment a node has no credential yet. It authenticates on a
# single-use, expiring, name-bound code instead.
#
# /bootstrap serves the bootstrap *template* - a script with no pairing code in
# it. It cannot pair, cannot authenticate and cannot read anything; it can check
# the machine and then stop. That is what makes `curl ... | sh` reasonable to hand
# to someone: the downloaded program has no authority until an operator types a
# code into it.
UNAUTHENTICATED = {"/pair", "/bootstrap"}
declared = agent_section.count("@agent_router.")
scoped = agent_section.count('node.require("')
assert declared - len(UNAUTHENTICATED) == scoped, (
@@ -783,9 +794,10 @@ def test_every_node_route_uses_node_credential_authentication() -> None:
# Each named exception must actually exist, so a rename cannot leave the list
# quietly permitting a route that is no longer there.
for path in UNAUTHENTICATED:
assert f'@agent_router.post("{path}"' in agent_section, (
f"{path} is listed as unauthenticated but no such route exists"
)
assert (
f'@agent_router.post("{path}"' in agent_section
or f'@agent_router.get("{path}"' in agent_section
), f"{path} is listed as unauthenticated but no such route exists"
def test_the_csrf_exemption_covers_the_node_surface_and_nothing_else() -> None:

444
web/assets/nodes.js Normal file
View File

@@ -0,0 +1,444 @@
/*
* The nodes view of the administrative console: list them, add one.
*
* Rules this file follows deliberately, and which the tests assert:
*
* - The pairing code and the rendered script are shown once and then discarded.
* They are never written to storage, never logged, and never put in a URL. There is
* no "copy the code again" button because the controller cannot produce it again —
* only an Argon2id hash is kept. A second button would be a lie about what the
* server can do.
* - No value from the API is ever turned into markup. Everything goes in through
* `api.el` / `textContent`. A node name is operator-supplied and ends up in the
* page, so it is treated as untrusted text.
* - Every non-GET request goes through `api.request`, which attaches the CSRF token.
* Nothing here can turn that off.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
var el = api.el;
var setText = api.setText;
var query = api.query;
var clear = api.clear;
var ORGANIZATION_ID = ''; // set from the session; see boot()
function say(message, kind) {
api.showToast(message, kind || 'info');
}
/* ------------------------------------------------------------------ list */
function statusBadge(status, schedulable, reason) {
var badge = el('span', 'status-badge status-badge--' + (status || 'unknown'));
setText(badge, status || 'unknown');
badge.title = reason || '';
if (status === 'online' && schedulable === false) {
var note = el('span', 'status-badge__note');
setText(note, 'not schedulable');
badge.appendChild(note);
}
return badge;
}
function deviceSummary(devices) {
if (!devices || !devices.length) {
return 'no devices reported';
}
return devices
.map(function (device) {
var label = device.label || device.kind;
if (device.vram_total_mb) {
return (
label +
' (' +
device.vram_free_mb +
'/' +
device.vram_total_mb +
' MiB VRAM)'
);
}
if (device.ram_total_mb) {
return label + ' (' + device.ram_free_mb + '/' + device.ram_total_mb + ' MiB RAM)';
}
return label;
})
.join(', ');
}
function lastSeenText(node) {
if (!node.last_heartbeat_at) {
return 'never';
}
var parsed = new Date(node.last_heartbeat_at);
if (isNaN(parsed.getTime())) {
return 'unknown';
}
var seconds = Math.round((Date.now() - parsed.getTime()) / 1000);
if (seconds < 0) {
return 'just now';
}
if (seconds < 60) {
return seconds + 's ago';
}
if (seconds < 3600) {
return Math.round(seconds / 60) + 'm ago';
}
if (seconds < 86400) {
return Math.round(seconds / 3600) + 'h ago';
}
return Math.round(seconds / 86400) + 'd ago';
}
function renderNodeRow(node) {
var row = el('tr');
var nameCell = el('td');
var name = el('strong');
setText(name, node.name);
nameCell.appendChild(name);
if (node.stored_status && node.stored_status !== node.status) {
var stale = el('small', 'table-note table-note--warn');
setText(stale, 'stored as ' + node.stored_status);
nameCell.appendChild(stale);
}
row.appendChild(nameCell);
var statusCell = el('td');
statusCell.appendChild(statusBadge(node.status, node.schedulable, node.status_reason));
row.appendChild(statusCell);
var localityCell = el('td');
setText(localityCell, node.locality || '—');
row.appendChild(localityCell);
var devicesCell = el('td');
setText(devicesCell, deviceSummary(node.devices));
row.appendChild(devicesCell);
var seenCell = el('td');
setText(seenCell, lastSeenText(node));
row.appendChild(seenCell);
var reasonCell = el('td', 'table-note');
setText(reasonCell, node.status_reason || '');
row.appendChild(reasonCell);
var actionsCell = el('td');
if (node.status === 'online' || node.status === 'provisioning') {
actionsCell.appendChild(
actionButton('Drain', function () {
setStatus(node, 'draining');
})
);
}
if (node.status === 'draining' || node.status === 'offline') {
actionsCell.appendChild(
actionButton('Return to service', function () {
setStatus(node, 'online');
})
);
}
if (node.status !== 'retired') {
actionsCell.appendChild(
actionButton('Retire', function () {
setStatus(node, 'retired');
})
);
}
row.appendChild(actionsCell);
return row;
}
function actionButton(label, handler) {
var button = el('button', 'button button--quiet button--small');
button.type = 'button';
setText(button, label);
button.addEventListener('click', handler);
return button;
}
function renderNodes(nodes) {
var container = query('#node-list');
if (!container) {
return;
}
clear(container);
if (!nodes.length) {
var empty = el('p', 'empty-state');
setText(empty, 'No nodes yet. Add one, then run the command it gives you on that machine.');
container.appendChild(empty);
return;
}
var table = el('table', 'admin-table');
var head = el('thead');
var headRow = el('tr');
['Node', 'Status', 'Where', 'Devices', 'Last seen', 'Why', ''].forEach(function (title) {
var cell = el('th');
setText(cell, title);
headRow.appendChild(cell);
});
head.appendChild(headRow);
table.appendChild(head);
var body = el('tbody');
nodes.forEach(function (node) {
body.appendChild(renderNodeRow(node));
});
table.appendChild(body);
container.appendChild(table);
}
function loadNodes() {
var path = ORGANIZATION_ID
? '/api/compute/nodes?organization_id=' + encodeURIComponent(ORGANIZATION_ID)
: '/api/compute/nodes';
return api.request(path).then(
function (nodes) {
renderNodes(nodes);
},
function (error) {
say(api.describeError(error), 'error');
}
);
}
function setStatus(node, status) {
api.request('/api/compute/nodes/' + encodeURIComponent(node.id) + '/status', {
method: 'PATCH',
body: { status: status },
}).then(
function () {
say(node.name + ' is now ' + status + '.', 'success');
return loadNodes();
},
function (error) {
say(api.describeError(error), 'error');
}
);
}
/* ------------------------------------------------------------------ add */
function showPairing(result, code, expiresAt, nodeName) {
var card = query('#pairing-result-card');
if (!card) {
return;
}
var note = query('#pairing-node-name-note');
if (note) {
setText(note, 'For the machine you will call ' + nodeName + '.');
}
var commandField = query('#pairing-command');
if (commandField) {
// .value, never innerHTML: this is text a person will paste, and it contains a
// code. The server builds it so the console and a shell user get the same command.
commandField.value = result.command || '';
}
var codeField = query('#pairing-code');
if (codeField) {
setText(codeField, code);
}
var hint = query('#pairing-code-hint');
if (hint && expiresAt) {
var parsed = new Date(expiresAt);
if (!isNaN(parsed.getTime())) {
setText(
hint,
'Expires at ' +
parsed.toLocaleTimeString() +
'. Shown once — only a hash is kept, so it cannot be shown again.'
);
}
}
var warning = query('#pairing-warning');
if (warning) {
warning.hidden = false;
}
card.hidden = false;
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function onAddNode(event) {
event.preventDefault();
var submit = query('#add-node-submit');
var nameField = query('#node-name');
var name = String(nameField && nameField.value ? nameField.value : '').trim();
if (!name) {
say('A node needs a name.', 'error');
if (nameField) {
nameField.focus();
}
return;
}
var localityField = query('#node-locality');
var domainField = query('#node-trust-domain');
var controllerField = query('#node-controller');
var rootField = query('#node-as-root');
api.disable(submit, true);
api.request('/api/compute/pairing-script', {
method: 'POST',
body: {
node_name: name,
locality: String(localityField && localityField.value ? localityField.value : '').trim() || null,
trust_domain: String(domainField && domainField.value ? domainField.value : 'local'),
controller_url: String(controllerField && controllerField.value ? controllerField.value : '').trim(),
as_root: Boolean(rootField && rootField.checked),
ttl_seconds: 1800,
},
}).then(
function (result) {
api.disable(submit, false);
// Read once, displayed once, and not kept. The response is the only place these
// exist: the controller stores an Argon2id hash of the code and cannot produce
// it again, which is why there is no "show the code" button below.
//
// Taken verbatim rather than recovered from the script text. An earlier version
// scraped the six digits out of `result.code` with a regex, which would have
// silently produced an empty code had the response shape ever changed - and a
// blank code looks exactly like a working page.
showPairing(result, String(result.code || ''), result.expires_at, name);
hideAddForm();
say('Pairing code created for ' + name + '.', 'success');
return loadNodes();
},
function (error) {
api.disable(submit, false);
say(api.describeError(error), 'error');
}
);
}
function hideAddForm() {
var card = query('#add-node-card');
if (card) {
card.hidden = true;
}
}
function showAddForm() {
var card = query('#add-node-card');
if (card) {
card.hidden = false;
var field = query('#node-name');
if (field) {
field.focus();
}
}
}
/* --------------------------------------------------------------- copying */
function copyText(text, what) {
if (!text) {
say('Nothing to copy.', 'error');
return;
}
var done = function () {
say(what + ' copied. Paste it somewhere you will find it.', 'success');
};
if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) {
global.navigator.clipboard.writeText(text).then(done, function () {
say('The browser would not copy it. Select the text and copy it by hand.', 'error');
});
return;
}
say('This browser will not copy automatically. Select the text and copy it by hand.', 'error');
}
/* ------------------------------------------------------------------ boot */
function boot() {
var openButton = query('#open-add-node');
if (openButton) {
openButton.addEventListener('click', showAddForm);
}
var cancelButton = query('#add-node-cancel');
if (cancelButton) {
cancelButton.addEventListener('click', hideAddForm);
}
var form = query('#add-node-form');
if (form) {
form.addEventListener('submit', onAddNode);
}
var doneButton = query('#pairing-done');
if (doneButton) {
doneButton.addEventListener('click', function () {
var card = query('#pairing-result-card');
if (card) {
card.hidden = true;
}
// The code is gone from the page for good. The controller keeps only a hash,
// so there is nothing to re-display and pretending otherwise would be a lie.
var codeField = query('#pairing-code');
if (codeField) {
setText(codeField, '');
}
var commandField = query('#pairing-command');
if (commandField) {
commandField.value = '';
}
});
}
var copyCommand = query('#pairing-copy-command');
if (copyCommand) {
copyCommand.addEventListener('click', function () {
var field = query('#pairing-command');
copyText(field ? field.value : '', 'Command');
});
}
var copyCode = query('#pairing-copy-code');
if (copyCode) {
copyCode.addEventListener('click', function () {
var field = query('#pairing-code');
copyText(field ? field.textContent : '', 'Code');
});
}
// The organization comes from the session rather than a picker, because a node is
// owned by exactly one tenant and picking the wrong one would put a machine in
// somebody else's inventory.
api
.request('/api/uadmin/summary')
.then(
function (summary) {
if (summary && summary.organization_id) {
ORGANIZATION_ID = summary.organization_id;
}
return loadNodes();
},
function () {
// A platform administrator may have no single organization. The list route
// falls back to the caller's own scope, so carry on.
return loadNodes();
}
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
global.UmbraNodes = { loadNodes: loadNodes };
})(window);

181
web/uadmin/nodes.html Normal file
View File

@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="UmbraTrans administrative console">
<title>Administration · UmbraTrans</title>
<link rel="icon" type="image/png" href="../assets/umbratrans-emblem.png">
<link rel="stylesheet" href="../assets/styles.css">
</head>
<body class="admin-page">
<header class="admin-topbar">
<div class="admin-topbar-inner">
<a class="admin-brand" href="./index.html">
<img src="../assets/umbratools-emblem.png" alt="" width="28" height="28" onerror="this.style.visibility='hidden'">
<span>UmbraTrans</span>
</a>
<div class="admin-identity">
<strong id="admin-display-name">Checking session…</strong>
<small id="admin-identity">No session</small>
</div>
<a class="button button--quiet" href="../settings.html">Sign out</a>
</div>
</header>
<main class="admin-main">
<nav class="admin-nav" aria-label="Sections">
<a class="admin-nav-link" href="./index.html#dashboard">Console</a>
<a class="admin-nav-link admin-nav-link--active" href="#nodes" data-view-link="nodes"
aria-current="page"><span aria-hidden="true">◈</span>Nodes</a>
<a class="admin-nav-link" href="./index.html">Everything else</a>
<a class="admin-nav-link" href="../settings.html">Your account</a>
</nav>
<section class="admin-view" id="view-nodes" data-view="nodes">
<div class="admin-heading">
<div>
<p class="eyebrow">Compute</p>
<h1>Nodes</h1>
<p>
Machines that can be given work. A node is <em>online</em> because its agent
heartbeated, not because it was created — so a node you have just added will
sit at <strong>provisioning</strong> until it runs the bootstrap, and a node
whose agent has stopped will read <strong>offline</strong> about 45 seconds
after its last heartbeat even though the stored status still says online.
</p>
</div>
<button class="button button--primary" type="button" id="open-add-node">
Add a node
</button>
</div>
<div class="card" id="add-node-card" hidden>
<div class="card-body">
<h2>Add a node</h2>
<p class="card-note">
This produces a one-line command and a six-digit code. Run the command on the
machine; it asks for the code, pairs with the controller, installs the agent
and registers it as a service.
</p>
<form id="add-node-form" novalidate>
<div class="field">
<label for="node-name">Node name</label>
<input id="node-name" name="node_name" type="text" autocomplete="off"
maxlength="120" required
aria-describedby="node-name-hint">
<small id="node-name-hint" class="field-hint">
The name this machine will report. The pairing code is bound to it, so a
code that ends up in the wrong hands cannot pair a different machine.
Lowercase letters, digits and dashes read best on a terminal.
</small>
</div>
<div class="field">
<label for="node-locality">Where it is</label>
<input id="node-locality" name="locality" type="text" autocomplete="off"
maxlength="120" placeholder="192.168.123.238 or a name you will recognise">
<small class="field-hint">For you, not for the system. Helps when six nodes
report at once.</small>
</div>
<div class="field">
<label for="node-trust-domain">Trust domain</label>
<select id="node-trust-domain" name="trust_domain">
<option value="local">local — on my own network</option>
<option value="remote">remote — reached across a network I do not control</option>
<option value="cloud">cloud — a hosted machine</option>
</select>
<small class="field-hint">
Used to refuse work across a boundary you have said is a boundary.
</small>
</div>
<div class="field">
<label for="node-controller">Controller address</label>
<input id="node-controller" name="controller_url" type="text" autocomplete="off"
value="http://192.168.123.14:8442">
<small class="field-hint">
The address the node will reach us on, from the node's side. If the node is
on another machine, use an address <em>it</em> can resolve.
</small>
</div>
<div class="field field--check">
<input id="node-as-root" name="as_root" type="checkbox">
<label for="node-as-root">
Expect to run as root on an exclusive machine
</label>
<small class="field-hint">
Chooses the expected install layout — <code>/opt/umbra</code> with a system
service running as its own account, or the user's own directories with a
user service. The script checks for root when it runs and takes whichever
branch is actually available, so this is a hint, not a requirement.
</small>
</div>
<div class="form-actions">
<button class="button button--primary" type="submit" id="add-node-submit">
Create the pairing code
</button>
<button class="button button--quiet" type="button" id="add-node-cancel">
Cancel
</button>
</div>
</form>
</div>
</div>
<div class="card" id="pairing-result-card" hidden>
<div class="card-body">
<h2>Run this on the machine</h2>
<p class="card-note" id="pairing-node-name-note"></p>
<div class="field">
<label for="pairing-command">Command</label>
<textarea id="pairing-command" class="code-block" rows="4" readonly
spellcheck="false"></textarea>
<small class="field-hint">
Read it before running it. It is a script fetched over the network, and the
command below is what fetches it.
</small>
</div>
<div class="field">
<label for="pairing-code">Six-digit code</label>
<p class="pairing-code" id="pairing-code"></p>
<small class="field-hint" id="pairing-code-hint">
Shown once. It expires, and it is spent by the first machine that uses it.
</small>
</div>
<div class="alert alert--warn" id="pairing-warning" hidden>
<strong>Write this down now.</strong>
<span id="pairing-warning-text"></span>
If you lose it, create a new one — a lost code is not a problem, but a code
that has expired is a wasted trip.
</div>
<div class="form-actions">
<button class="button" type="button" id="pairing-copy-command">Copy the command</button>
<button class="button" type="button" id="pairing-copy-code">Copy the code</button>
<button class="button button--quiet" type="button" id="pairing-done">Done</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div id="node-list" class="table-wrap" aria-live="polite"></div>
</div>
</div>
</section>
</main>
<div class="toast-region" id="toast-region" aria-live="polite"></div>
<script src="../assets/api.js"></script>
<script src="../assets/nodes.js"></script>
</body>
</html>