feat(auth): forged browser requests are refused (#5031)
#5031 "Forged-request protection: SameSite cookie, per-session CSRF token on every non-GET, HTMX header". A page on another site could make a logged-in browser POST to Steward with the session cookie attached. - steward/auth/csrf.py: a per-session token, checked in one before_request for every POST/PUT/PATCH/DELETE (header X-CSRF-Token or form field csrf_token, constant-time compare). The bearer-authenticated host-agent ingest is exempt; /mcp never reaches Quart. - Every POST form renders {{ csrf_field() }}; base.html carries the token in a meta tag and adds it to every HTMX request; the dashboard's fetch() calls send it. - The session cookie is SameSite=Lax; login issues a fresh token. - PUBLIC_BASE_URL is now loaded from Settings at startup (it was only set when Settings → General was saved). - tests/test_csrf.py: refusal, acceptance, rotation, the ingest exemption, and a guard that every POST form in the templates carries the field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@
|
||||
<td class="td-actions">
|
||||
{% if row.state == 'active' %}
|
||||
<form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;"
|
||||
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>
|
||||
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -79,7 +79,7 @@
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Create a token</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/ai-access/tokens">
|
||||
<form method="post" action="/ai-access/tokens">{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label for="tok-name">Name</label>
|
||||
<input type="text" id="tok-name" name="name" required maxlength="128"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<td style="color:var(--text-muted);">{{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }}</td>
|
||||
<td class="td-actions">
|
||||
{% if state.state.value == 'firing' %}
|
||||
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">
|
||||
<form method="post" action="/alerts/{{ rule.id }}/acknowledge" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -83,12 +83,12 @@
|
||||
<td style="color:var(--text-muted);text-align:center;">{{ rule.consecutive_failures_required }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/alerts/rules/{{ rule.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-ghost">
|
||||
{% if rule.enabled %}Disable{% else %}Enable{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">
|
||||
<form method="post" action="/alerts/rules/{{ rule.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete rule {{ rule.name }}?')">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">
|
||||
<form method="post" action="/alerts/maintenance/{{ w.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete window "{{ w.name }}"?')">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div style="max-width:560px;margin:2rem auto;">
|
||||
<h1 class="page-title">New Maintenance Window</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="/alerts/maintenance">
|
||||
<form method="post" action="/alerts/maintenance">{{ csrf_field() }}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Window Name</label>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div style="max-width:600px;margin:2rem auto;">
|
||||
<h1 class="page-title">{% if rule %}Edit Rule{% else %}New Alert Rule{% endif %}</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">
|
||||
<form method="post" action="{% if rule %}/alerts/rules/{{ rule.id }}{% else %}/alerts/rules{% endif %}">{{ csrf_field() }}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Rule Name</label>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{% if view_editable and session.user_role == 'admin' %}
|
||||
<a href="/ansible/playbooks/edit/{{ view_source }}/{{ view_path }}" class="btn btn-sm">Edit</a>
|
||||
<form method="post" action="/ansible/playbooks/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete {{ view_path }}?');">
|
||||
onsubmit="return confirm('Delete {{ view_path }}?');">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ view_source }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ view_path }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
|
||||
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">{{ csrf_field() }}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<h3 class="section-title">Settings</h3>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<h3 class="section-title">New Group</h3>
|
||||
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">
|
||||
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
|
||||
<div class="form-group" style="flex:1;margin:0;">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="webservers" required>
|
||||
@@ -38,7 +38,7 @@
|
||||
<td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">
|
||||
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
|
||||
</form>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title" style="margin-bottom:0;">Target: {{ target.name }}</h1>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">
|
||||
<form method="post" action="/ansible/inventory/targets/{{ target.id }}">{{ csrf_field() }}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<h3 class="section-title">Connection</h3>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<h3 class="section-title">New Target</h3>
|
||||
<form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;">
|
||||
<form method="post" action="/ansible/inventory/targets" style="display:flex;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
|
||||
<div class="form-group" style="flex:1;margin:0;">
|
||||
<label>Name <span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">(used as Ansible hostname)</span></label>
|
||||
<input type="text" name="name" placeholder="webserver-01" required>
|
||||
@@ -47,7 +47,7 @@
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<a href="/ansible/inventory/targets/{{ tgt.id }}" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">
|
||||
<form method="post" action="/ansible/inventory/targets/{{ tgt.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete target {{ tgt.name }}?')">×</button>
|
||||
</form>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
automatically; you can also configure a local-dir source under <a href="/settings/ansible/">Settings → Ansible</a>.
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="/ansible/playbooks/save">
|
||||
<form method="post" action="/ansible/playbooks/save">{{ csrf_field() }}
|
||||
<input type="hidden" name="editing" value="{{ '1' if editing else '0' }}">
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
{% if editing %}
|
||||
<form method="post" action="/ansible/playbooks/delete" style="margin-top:1rem;"
|
||||
onsubmit="return confirm('Delete {{ playbook_path }}?');">
|
||||
onsubmit="return confirm('Delete {{ playbook_path }}?');">{{ csrf_field() }}
|
||||
<input type="hidden" name="source_name" value="{{ source_name }}">
|
||||
<input type="hidden" name="playbook_path" value="{{ playbook_path }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete playbook</button>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
|
||||
<div style="margin-left:auto;display:flex;gap:0.5rem;">
|
||||
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -54,15 +54,15 @@
|
||||
{% else %}due now{% endif %}
|
||||
</td>
|
||||
<td class="td-actions" style="white-space:nowrap;">
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/run-now" style="display:inline;">{{ csrf_field() }}
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Run now</button>
|
||||
</form>
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/toggle" style="display:inline;">
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button class="btn btn-sm btn-ghost" type="submit">{{ "Pause" if s.enabled else "Resume" }}</button>
|
||||
</form>
|
||||
<a href="?edit={{ s.id }}#form" class="btn btn-sm btn-ghost">Edit</a>
|
||||
<form method="post" action="/ansible/schedules/{{ s.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete schedule {{ s.name }}?');">
|
||||
onsubmit="return confirm('Delete schedule {{ s.name }}?');">{{ csrf_field() }}
|
||||
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -79,7 +79,7 @@
|
||||
{% set declared_names = (edit_variables or []) | map(attribute='name') | list %}
|
||||
<div class="card" id="form">
|
||||
<h2 class="section-title" style="margin-bottom:1rem;">{{ "Edit schedule" if editing else "New schedule" }}</h2>
|
||||
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">
|
||||
<form method="post" action="{{ '/ansible/schedules/' ~ editing.id if editing else '/ansible/schedules' }}">{{ csrf_field() }}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Name</label>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="flex:1;height:1px;background:var(--border-mid);"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<form method="post" action="/login">{{ csrf_field() }}
|
||||
{% if next_url %}<input type="hidden" name="next" value="{{ next_url }}">{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="text" name="username" placeholder="Username" autofocus required>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="card">
|
||||
<h2 class="page-title" style="margin-bottom:0.5rem;">First-Run Setup</h2>
|
||||
<p style="margin-bottom:1.5rem;color:var(--text-muted);font-size:0.9rem;">Create the initial admin account.</p>
|
||||
<form method="post" action="/setup">
|
||||
<form method="post" action="/setup">{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<script>
|
||||
// Every HTMX request carries this session's forged-request token; fetch()
|
||||
// callers read the same meta tag.
|
||||
document.addEventListener('htmx:configRequest', function (ev) {
|
||||
ev.detail.headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
|
||||
});
|
||||
</script>
|
||||
<title>{% block title %}Steward{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</summary>
|
||||
<form action="/d/{{ dashboard.id }}/edit/add/{{ w.key }}" method="post"
|
||||
class="add-widget-form"
|
||||
style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);">
|
||||
style="padding:0.75rem 0.85rem;padding-top:0;border-top:1px solid var(--border);">{{ csrf_field() }}
|
||||
<div style="display:grid;gap:0.5rem;padding-top:0.75rem;">
|
||||
<div>
|
||||
<label style="font-size:0.74rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;">
|
||||
|
||||
@@ -73,13 +73,14 @@
|
||||
}, gridEl);
|
||||
|
||||
if (!canEdit) return; // viewers get the static grid only
|
||||
var csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||
|
||||
function saveLayout() {
|
||||
var items = grid.save(false).map(function (n) {
|
||||
return { id: parseInt(n.id, 10), x: n.x, y: n.y, w: n.w, h: n.h };
|
||||
});
|
||||
fetch('/d/' + dashId + '/edit/layout', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ items: items }),
|
||||
});
|
||||
}
|
||||
@@ -101,7 +102,7 @@
|
||||
document.querySelectorAll('.add-widget-form').forEach(function (form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
fetch(form.action, { method: 'POST', body: new FormData(form) })
|
||||
fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-CSRF-Token': csrf } })
|
||||
.then(function (r) { return r.status === 204 ? '' : r.text(); })
|
||||
.then(function (html) {
|
||||
if (!html) return;
|
||||
@@ -126,7 +127,8 @@
|
||||
if (!rm) return;
|
||||
ev.preventDefault();
|
||||
var item = rm.closest('.grid-stack-item');
|
||||
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove, { method: 'POST' })
|
||||
fetch('/d/' + dashId + '/edit/remove/' + rm.dataset.remove,
|
||||
{ method: 'POST', headers: { 'X-CSRF-Token': csrf } })
|
||||
.then(function () { if (item) grid.removeWidget(item); });
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<details style="position:relative;">
|
||||
<summary class="btn btn-ghost btn-sm" style="cursor:pointer;list-style:none;">Rename</summary>
|
||||
<div style="position:absolute;right:0;top:calc(100% + 4px);background:var(--bg-card);border:1px solid var(--border-mid);border-radius:6px;padding:0.75rem;z-index:50;width:220px;box-shadow:0 4px 16px rgba(0,0,0,0.4);">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/rename" style="display:flex;gap:0.4rem;">{{ csrf_field() }}
|
||||
<input type="text" name="name" value="{{ dash.name }}"
|
||||
style="flex:1;font-size:0.85rem;padding:0.3rem 0.5rem;" required>
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
@@ -45,19 +45,19 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/toggle-share" style="margin:0;" title="{{ 'Make private' if dash.is_shared else 'Share with all users' }}">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">{{ '🔒' if dash.is_shared else '🔗' }}</button>
|
||||
</form>
|
||||
|
||||
{% if not dash.is_default %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if my_dashboards | length > 1 %}
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">
|
||||
onsubmit="return confirm('Delete \'{{ dash.name }}\'?')">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0;">
|
||||
<a href="/d/{{ dash.id }}" class="btn btn-ghost btn-sm">View</a>
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">
|
||||
<form method="post" action="/dashboards/{{ dash.id }}/set-default" style="margin:0;" title="Set as my default">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">★</button>
|
||||
</form>
|
||||
{# Admins can also edit system dashboards #}
|
||||
@@ -115,7 +115,7 @@
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">New Dashboard</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/dashboards/create">
|
||||
<form method="post" action="/dashboards/create">{{ csrf_field() }}
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. Network Overview" required>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/revoke/{{ token.id }}"
|
||||
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">
|
||||
style="margin:0;" onsubmit="return confirm('Revoke this share link?')">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Create Share Link</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/create">
|
||||
<form method="post" action="/d/{{ dashboard.id }}/share/create">{{ csrf_field() }}
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<label>Label <span style="color:var(--text-muted);font-size:0.78rem;">(optional)</span></label>
|
||||
<input type="text" name="label" placeholder="e.g. Public display screen">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="margin:0;"
|
||||
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">
|
||||
onsubmit="return confirm('Delete host {{ host.name }}? Monitors and history are removed.');">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -78,7 +78,7 @@
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm btn-ghost" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<details style="margin-top:0.75rem;">
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary>
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">{{ csrf_field() }}
|
||||
<input type="hidden" name="host_id" value="{{ host.id }}">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
@@ -148,7 +148,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="/ansible/inventory/targets/{{ linked_target.id }}" class="btn btn-ghost btn-sm">Edit target</a>
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
|
||||
<input type="hidden" name="action" value="unlink">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Unlink</button>
|
||||
</form>
|
||||
@@ -157,7 +157,7 @@
|
||||
{% if ansible_sources %}
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">Run a playbook against this host</summary>
|
||||
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">
|
||||
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label>Source</label>
|
||||
<select name="source_name" required
|
||||
@@ -202,7 +202,7 @@
|
||||
</p>
|
||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
{% if linkable_targets %}
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="display:flex;gap:0.5rem;align-items:center;">{{ csrf_field() }}
|
||||
<input type="hidden" name="action" value="link">
|
||||
<select name="target_id">
|
||||
{% for tgt in linkable_targets %}<option value="{{ tgt.id }}">{{ tgt.name }} ({{ tgt.address }})</option>{% endfor %}
|
||||
@@ -210,7 +210,7 @@
|
||||
<button type="submit" class="btn btn-sm">Link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">
|
||||
<form method="post" action="/hosts/{{ host.id }}/ansible-link" style="margin:0;">{{ csrf_field() }}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Create target from this host</button>
|
||||
</form>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div style="max-width:560px;margin:2rem auto;">
|
||||
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
|
||||
<div class="card">
|
||||
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">
|
||||
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">
|
||||
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Delete {{ host.name }}?')">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% set lbl = "font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:0.2rem;" %}
|
||||
|
||||
<div class="card" style="max-width:760px;">
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">
|
||||
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label style="{{ lbl }}">Type</label>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">
|
||||
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
|
||||
<div>
|
||||
<label style="{{ lbl }}">Type</label>
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
{% if session.user_role in ['operator', 'admin'] %}
|
||||
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
|
||||
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
|
||||
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">
|
||||
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
|
||||
{{ "Pause" if m.enabled else "Resume" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">
|
||||
onsubmit="return confirm('Delete monitor {{ m.name }}?');">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
|
||||
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
|
||||
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
|
||||
@@ -79,7 +79,7 @@
|
||||
hx-post="/settings/plugins/reload/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
|
||||
</form>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<input type="hidden" name="download_url" value="{{ plugin.download_url }}">
|
||||
<input type="hidden" name="checksum_sha256" value="{{ plugin.checksum_sha256 }}">
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
hx-post="/settings/plugins/restart/"
|
||||
hx-target="#restart-banner"
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline;margin:0;">
|
||||
style="display:inline;margin:0;">{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
|
||||
Restart now
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;">Click to select — add to a host's <code>authorized_keys</code> to authorize Steward manually.</span>
|
||||
</div>
|
||||
<form method="post" action="/settings/ansible/generate-key"
|
||||
onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">
|
||||
onsubmit="return confirm('Regenerate the managed key? Already-provisioned hosts will reject Steward until you re-provision them.');">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Regenerate key</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/settings/ansible/generate-key">
|
||||
<form method="post" action="/settings/ansible/generate-key">{{ csrf_field() }}
|
||||
<button type="submit" class="btn">Generate managed key</button>
|
||||
<span style="color:var(--text-muted);font-size:0.78rem;margin-left:0.5rem;">ed25519 — created server-side, nothing to paste.</span>
|
||||
</form>
|
||||
@@ -56,7 +56,7 @@
|
||||
Used by every playbook run (manual and alert-triggered). Stored on the server and
|
||||
never shown again after saving — leave a field blank to keep the current value.
|
||||
</p>
|
||||
<form method="post" action="/settings/ansible/credentials">
|
||||
<form method="post" action="/settings/ansible/credentials">{{ csrf_field() }}
|
||||
<div class="form-group">
|
||||
<label>SSH private key {{ (cfg_badge if ssh_key_set else not_set_badge) | safe }}</label>
|
||||
<textarea name="ssh_private_key" rows="4"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% set active_tab = "auth" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/auth/">
|
||||
<form method="post" action="/settings/auth/">{{ csrf_field() }}
|
||||
<div style="display:grid;gap:1.25rem;max-width:640px;">
|
||||
|
||||
{# ── OIDC ─────────────────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% set active_tab = "general" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/general/">
|
||||
<form method="post" action="/settings/general/">{{ csrf_field() }}
|
||||
<div class="card" style="max-width:640px;">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">General</h2>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Settings — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<form method="post" action="/settings/">
|
||||
<form method="post" action="/settings/">{{ csrf_field() }}
|
||||
<div style="display:grid;gap:1.5rem;">
|
||||
|
||||
{# ── General ──────────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% set active_tab = "notifications" %}
|
||||
{% include "settings/_tabs.html" %}
|
||||
|
||||
<form method="post" action="/settings/notifications/">
|
||||
<form method="post" action="/settings/notifications/">{{ csrf_field() }}
|
||||
<div style="display:grid;gap:1.25rem;max-width:640px;">
|
||||
|
||||
{# ── SMTP ──────────────────────────────────────────────────────────────── #}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<p style="color:var(--text-muted);font-size:0.88rem;margin-bottom:1.25rem;max-width:620px;">{{ plugin.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">
|
||||
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">{{ csrf_field() }}
|
||||
|
||||
{# ── Enable toggle ────────────────────────────────────────────────────────── #}
|
||||
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<form method="post" action="/settings/plugins/restart/"
|
||||
hx-post="/settings/plugins/restart/" hx-target="#restart-banner" hx-swap="outerHTML"
|
||||
onsubmit="return confirm('Restart the app now? This will briefly interrupt all monitoring.')"
|
||||
style="margin:0;">
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Restart App</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@
|
||||
{% if orphan.removable %}
|
||||
<form method="post" action="/settings/plugins/orphans/{{ orphan.name }}/remove/"
|
||||
onsubmit="return confirm('Remove stored settings for "{{ orphan.name }}"? Its saved configuration, including any credentials, will be discarded. This cannot be undone.');"
|
||||
style="margin:0;flex-shrink:0;">
|
||||
style="margin:0;flex-shrink:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="font-size:0.78rem;">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="max-width:560px;">
|
||||
|
||||
{# ── Schedule ─────────────────────────────────────────────────────────────── #}
|
||||
<form method="post" action="/settings/reports/">
|
||||
<form method="post" action="/settings/reports/">{{ csrf_field() }}
|
||||
<div class="card" style="margin-bottom:1.25rem;">
|
||||
<h2 class="section-title" style="margin-bottom:1.25rem;">Weekly Digest</h2>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:1rem;">
|
||||
Send a report immediately regardless of schedule. Useful for testing.
|
||||
</p>
|
||||
<form method="post" action="/settings/reports/send-now/">
|
||||
<form method="post" action="/settings/reports/send-now/">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-ghost">Send report now</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<form method="post" action="/settings/thresholds/">
|
||||
<form method="post" action="/settings/thresholds/">{{ csrf_field() }}
|
||||
<div class="card" style="max-width:640px;">
|
||||
<h2 class="section-title" style="margin-bottom:0.5rem;">Monitoring thresholds</h2>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
|
||||
|
||||
Reference in New Issue
Block a user