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 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user