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
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:
@@ -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…</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>
|
||||
@@ -9,6 +9,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||
import ReverseProxySettings from "@/components/ReverseProxySettings.vue";
|
||||
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
|
||||
import BackupScheduleSettings from "@/components/BackupScheduleSettings.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>
|
||||
</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">
|
||||
<h2>Application URL</h2>
|
||||
<p class="section-desc">
|
||||
|
||||
Reference in New Issue
Block a user