terminal: per-Authentik-user OS-user isolation; deny unmapped users

Restores the kernel-level isolation the pre-cutover ttyd-session.sh had,
but keeps the multi-session lobby UX:

- ttyd.service gets `-H X-authentik-username` back. `tmux-attach.sh` reads
  $TTYD_USER, looks up the local part in /etc/ttyd-user-map, denies the
  connection (no fallback to wizard) if there's no mapping, otherwise
  `sudo -n -H -u <os_user> tmux …`. Each Authentik identity → its own
  Unix user → its own `/tmp/tmux-<uid>/default` socket.
- tmux-api scopes every request to the same OS user via the same header.
  Adds /whoami so the lobby HTML can preflight access and render
  "logged in as <os_user> (<authentik>)" instead of leaving the user to
  discover the deny via a reconnect loop.
- Commits /etc/ttyd-user-map and the matching /etc/sudoers.d/ttyd-users
  fragment under files/devvm/ so future operators see one canonical
  source of truth. Current mappings: vbarzin → wizard, emil.barzin → emo.

Adding a user is now: append a line to ttyd-user-map + a NOPASSWD
sudoers line + `useradd -m`. README walks through it.

No Terraform changes — this is all DevVM-side + lobby JS.
This commit is contained in:
Viktor Barzin 2026-05-13 19:25:55 +00:00 committed by Viktor Barzin
parent aff4f67671
commit 9fce3c7b09
7 changed files with 316 additions and 65 deletions

View file

@ -109,9 +109,10 @@
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
<script>
(function() {
(async function() {
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
const SESSIONS_API = '/api/sessions/sessions';
const WHOAMI_API = '/api/sessions/whoami';
const params = new URLSearchParams(location.search);
const rawArg = params.get('arg');
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
@ -135,6 +136,46 @@
return e;
}
function showAccessDenied(detail) {
document.getElementById('terminal').classList.add('hidden');
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
const lobby = document.getElementById('lobby');
clearChildren(lobby);
const h = document.createElement('h1');
h.className = 'lobby-header';
h.textContent = 'Access denied';
lobby.appendChild(h);
const p = document.createElement('p');
p.className = 'lobby-sub';
p.textContent = detail || 'You do not have a terminal account on this server.';
lobby.appendChild(p);
const p2 = document.createElement('p');
p2.className = 'lobby-sub';
p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.';
lobby.appendChild(p2);
lobby.classList.add('visible');
document.title = 'access denied';
}
// Preflight: ask tmux-api who we are. 403 = unmapped Authentik user → deny.
let whoami = null;
try {
const resp = await fetch(WHOAMI_API, { credentials: 'same-origin' });
if (resp.status === 401 || resp.status === 403) {
showAccessDenied((await resp.text()).trim());
return;
}
if (!resp.ok) {
showAccessDenied('Preflight failed: HTTP ' + resp.status);
return;
}
whoami = await resp.json();
} catch (err) {
showAccessDenied('Preflight failed: ' + err.message);
return;
}
if (!validArg) {
// ============================================================
// LOBBY MODE — no valid ?arg=, show session picker
@ -143,7 +184,9 @@
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
document.getElementById('lobby').classList.add('visible');
document.title = 'tmux sessions';
document.title = 'tmux sessions (' + whoami.osUser + ')';
document.querySelector('#lobby .lobby-sub').textContent =
'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.';
const listEl = document.getElementById('session-list');
const newNameEl = document.getElementById('new-name');
@ -300,7 +343,7 @@
term.open(document.getElementById('terminal'));
fitAddon.fit();
document.title = 'tmux: ' + validArg;
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
function sendInput(data) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;