feat(6867): Settings → Config has a Reverse proxy section that shows which count is right
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / integration (push) Successful in 2m8s
CI & Build / Build & push image (push) Successful in 33s

The section opens with a verdict on the admin's own request: the
address Grimoire reads them as, or, through Cloudflare, the count to set
with a button that sets it. Below it are the count with where it came
from, a table of what counts 0-3 would read, and the raw forwarding
chain. A saved count can be cleared back to the environment's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 09:59:05 -05:00
co-authored by Claude Opus 5.5
parent 753a6ccb31
commit d2267b615f
2 changed files with 320 additions and 0 deletions
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { apiGet, apiPut, apiErrorMessage } from "@/api/client";
import { useToastStore } from "@/stores/toast";
/**
* How many reverse proxies stand in front of Grimoire, checked against the
* admin's own request.
*
* Each proxy appends to X-Forwarded-For, and Grimoire reads the entry this many
* in from the right as the visitor's address. Every sign-in rate limit and
* audit line keys on that address, and the scheme read the same way decides
* whether the session cookie is Secure. Too low a count makes every visitor
* read as the proxy, so they share one rate limit and one bad sign-in run
* blocks everyone. Behind Cloudflare's proxied DNS the usual count is 2.
*/
interface ReadoutRow { hops: number; client: string; https: boolean }
interface Readout {
peer: string;
forwarded_for: string[];
forwarded_proto: string[];
cloudflare: boolean;
cloudflare_client: string | null;
rows: ReadoutRow[];
suggested: number | null;
}
interface ProxyConfig {
hops: number;
source: "setting" | "env" | "default";
env_hops: number;
max_hops: number;
readout: Readout;
}
const toast = useToastStore();
const config = ref<ProxyConfig | null>(null);
const loadError = ref("");
const draft = ref<number>(1);
const saving = ref(false);
const saved = ref(false);
async function load() {
loadError.value = "";
try {
config.value = await apiGet<ProxyConfig>("/api/admin/proxy");
draft.value = config.value.hops;
} catch (e) {
loadError.value = apiErrorMessage(e, "The proxy settings could not be loaded.");
}
}
async function save(hops: number | null) {
saving.value = true;
saved.value = false;
try {
config.value = await apiPut<ProxyConfig>("/api/admin/proxy", { hops });
draft.value = config.value.hops;
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} catch (e) {
toast.show(apiErrorMessage(e, "Failed to save the proxy count"), "error");
} finally {
saving.value = false;
}
}
const readout = computed(() => config.value?.readout ?? null);
const inForce = computed(() => readout.value?.rows.find((r) => r.hops === config.value?.hops) ?? null);
// The verdict that leads the section. A Cloudflare request names the count to
// set, since Cloudflare reports the visitor's address; any other road can only
// show the address read and let the admin judge whether it is theirs.
const verdict = computed<"set-suggested" | "cloudflare-unmatched" | "check" | null>(() => {
const r = readout.value;
if (!r || !config.value) return null;
if (r.cloudflare && r.suggested !== null && r.suggested !== config.value.hops) return "set-suggested";
if (r.cloudflare && r.suggested === null) return "cloudflare-unmatched";
return "check";
});
const sourceLabel = computed(() => {
const c = config.value;
if (!c) return "";
if (c.source === "setting") return "Saved here.";
if (c.source === "env") return `From TRUSTED_PROXY_HOPS in the environment (${c.env_hops}).`;
return "The default.";
});
const draftValid = computed(() =>
Number.isInteger(draft.value) && draft.value >= 0 && draft.value <= (config.value?.max_hops ?? 5),
);
onMounted(load);
</script>
<template>
<div class="proxy-settings">
<p v-if="loadError" class="error-msg">
{{ loadError }}
<button type="button" class="btn-ghost btn-compact" @click="load">Try again</button>
</p>
<p v-else-if="!config" class="state-msg">Reading this request&hellip;</p>
<template v-else-if="readout">
<div v-if="verdict === 'set-suggested'" class="proxy-warning" role="alert">
<p class="proxy-warning-title">Set the proxy count to {{ readout.suggested }}</p>
<p class="proxy-warning-body">
This request came through Cloudflare, which reports your address as
<code>{{ readout.cloudflare_client }}</code>. At {{ config.hops }}, Grimoire reads
<code>{{ inForce?.client }}</code> instead, so visitors share Cloudflare's addresses
and one sign-in limit.
</p>
<button type="button" class="btn-primary" :disabled="saving" @click="save(readout.suggested)">
Use {{ readout.suggested }}
</button>
</div>
<div v-else-if="verdict === 'cloudflare-unmatched'" class="proxy-warning" role="alert">
<p class="proxy-warning-title">This request came through Cloudflare</p>
<p class="proxy-warning-body">
No count reads the address Cloudflare reports for you. Check the forwarding chain below,
and that your own proxy passes Cloudflare's <code>X-Forwarded-For</code> on.
</p>
</div>
<p v-else class="proxy-verdict">
Grimoire reads this request as coming from <code>{{ inForce?.client || "an unknown address" }}</code>
<template v-if="inForce?.https"> over HTTPS</template>. If that is your address, the count
is right. If it is your proxy's, raise the count.
</p>
<div class="field proxy-field">
<label for="proxy-hops">Proxies in front of Grimoire</label>
<div class="proxy-input-row">
<input
id="proxy-hops"
v-model.number="draft"
type="number"
min="0"
:max="config.max_hops"
step="1"
class="fs-input input proxy-hops-input"
/>
<button
type="button"
class="btn-primary"
:disabled="saving || !draftValid || draft === config.hops"
@click="save(draft)"
>
{{ saving ? "Saving..." : "Save" }}
</button>
<button
v-if="config.source === 'setting'"
type="button"
class="btn-ghost"
:disabled="saving"
@click="save(null)"
>
Use the environment's ({{ config.env_hops }})
</button>
<span v-if="saved" class="saved-msg">Saved. It applies to the next request.</span>
</div>
<p class="field-hint">
{{ sourceLabel }} One proxy (Traefik, Nginx, Caddy) is 1. Cloudflare in front of your own
proxy is 2. 0 ignores the forwarding headers and uses the connecting address.
</p>
</div>
<table class="proxy-table">
<caption>What each count makes of this request</caption>
<thead>
<tr><th class="num">Count</th><th>Reads you as</th><th>HTTPS</th><th></th></tr>
</thead>
<tbody>
<tr
v-for="row in readout.rows"
:key="row.hops"
:class="{ 'proxy-row-current': row.hops === config.hops }"
>
<td class="num">{{ row.hops }}</td>
<td><code>{{ row.client || "unknown" }}</code></td>
<td>{{ row.https ? "yes" : "no" }}</td>
<td class="proxy-row-tags">
<span v-if="row.hops === config.hops" class="proxy-tag">in force</span>
<span v-if="row.hops === readout.suggested && row.hops !== config.hops" class="proxy-tag proxy-tag-suggested">
suggested
</span>
</td>
</tr>
</tbody>
</table>
<dl class="proxy-chain">
<dt>Connecting address</dt>
<dd><code>{{ readout.peer || "unknown" }}</code></dd>
<dt>X-Forwarded-For</dt>
<dd v-if="readout.forwarded_for.length"><code>{{ readout.forwarded_for.join(", ") }}</code></dd>
<dd v-else class="proxy-absent">not sent</dd>
<dt>X-Forwarded-Proto</dt>
<dd v-if="readout.forwarded_proto.length"><code>{{ readout.forwarded_proto.join(", ") }}</code></dd>
<dd v-else class="proxy-absent">not sent</dd>
</dl>
</template>
</div>
</template>
<style scoped>
.proxy-warning {
margin-bottom: var(--fs-space-4);
padding: var(--fs-space-3);
border: 1px solid var(--fs-warning);
border-radius: var(--fs-radius-md);
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
}
.proxy-warning-title {
margin: 0 0 var(--fs-space-2);
font-weight: 600;
color: var(--fs-warning-fg);
}
.proxy-warning-body {
margin: 0 0 var(--fs-space-3);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.proxy-verdict {
margin: 0 0 var(--fs-space-4);
font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary);
}
.proxy-field {
margin-bottom: var(--fs-space-4);
}
.proxy-input-row {
display: flex;
align-items: center;
gap: var(--fs-space-3);
flex-wrap: wrap;
}
.proxy-hops-input {
width: 5rem;
}
.proxy-table {
width: 100%;
max-width: 560px;
border-collapse: collapse;
font-size: var(--fs-size-body-sm);
margin-bottom: var(--fs-space-4);
}
.proxy-table caption {
text-align: left;
font-size: var(--fs-size-label);
color: var(--fs-text-secondary);
margin-bottom: var(--fs-space-2);
}
.proxy-table th,
.proxy-table td {
text-align: left;
padding: var(--fs-space-1) var(--fs-space-3);
border-bottom: 1px solid var(--fs-border-color);
}
.proxy-table th {
font-size: var(--fs-size-tiny);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
font-weight: 500;
}
.proxy-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
width: 4rem;
}
.proxy-row-current td {
background: var(--fs-surface-hover);
font-weight: 600;
}
.proxy-row-tags {
white-space: nowrap;
}
.proxy-tag {
display: inline-block;
padding: 0 var(--fs-space-2);
border-radius: var(--fs-radius-pill);
font-size: var(--fs-size-tiny);
font-weight: 500;
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-active);
}
.proxy-tag-suggested {
color: var(--fs-warning-fg);
border-color: var(--fs-warning);
}
.proxy-chain {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--fs-space-1) var(--fs-space-4);
margin: 0;
font-size: var(--fs-size-body-sm);
}
.proxy-chain dt {
color: var(--fs-text-secondary);
}
.proxy-chain dd {
margin: 0;
overflow-wrap: anywhere;
}
.proxy-absent {
font-style: italic;
color: var(--fs-text-tertiary);
}
</style>
+11
View File
@@ -9,6 +9,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
import type { User } from "@/types/auth"; import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import MomentsSettings from "@/components/MomentsSettings.vue"; import MomentsSettings from "@/components/MomentsSettings.vue";
import ReverseProxySettings from "@/components/ReverseProxySettings.vue";
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue"; import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue"; import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
import RestoreBackupDialog from "@/components/RestoreBackupDialog.vue"; import RestoreBackupDialog from "@/components/RestoreBackupDialog.vue";
@@ -2664,6 +2665,16 @@ async function deleteUser(userId: number) {
<div v-else class="empty-msg">Nothing asked yet.</div> <div v-else class="empty-msg">Nothing asked yet.</div>
</section> </section>
<section class="settings-section full-width">
<h2>Reverse proxy</h2>
<p class="section-desc">
How many proxies a request passes through before it reaches Grimoire. Sign-in rate
limits and the audit log key on the visitor's address, read through this count; if it
is too low, every visitor reads as the proxy and one run of bad sign-ins blocks everyone.
</p>
<ReverseProxySettings />
</section>
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Application URL</h2> <h2>Application URL</h2>
<p class="section-desc"> <p class="section-desc">