Skip to content

Commit 756a7f1

Browse files
authored
feat: static trustedIPs helper for entrypoint forwardedHeaders (#118)
Adds a guided Trusted IPs helper in the Static Config editor that writes forwardedHeaders.trustedIPs onto an entrypoint, so Traefik trusts X-Forwarded-For from proxies you control (Cloudflare, your own LAN/proxies). - New POST /api/static/trusted-ips/preview parses the current static YAML, merges the selected sources additively (dedup by normalized network) and returns the exact new raw plus a preview (existing / added / invalid / final). It never writes to disk; the result is saved through the existing static-config save path, so it keeps the backup and restart flow and works on the Host and on remote agents alike. - Cloudflare IPv4+IPv6 ranges are hardcoded with a capture date and refreshed on release (no runtime fetch). Private-range and free-form CIDR sources are also offered; invalid CIDRs are flagged and skipped. - UI: a Trusted IPs button in the Static Config header (Host and agent), a preview modal with a global/restart warning that links to the Client IP Diagnostic, and a trustedIPs count badge on each entrypoint row. - Entrypoint edits preserve forwardedHeaders, so the helper never clobbers a hand-authored value. - Docs: a Trusted IPs helper section in docs/static.md including a note on how to refresh the hardcoded Cloudflare ranges.
1 parent 0ef0f8c commit 756a7f1

5 files changed

Lines changed: 333 additions & 0 deletions

File tree

app.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2684,6 +2684,143 @@ def api_static_section_update():
26842684
return jsonify({'error': str(e)}), 500
26852685

26862686

2687+
# Cloudflare edge ranges for forwardedHeaders.trustedIPs, captured 2026-07-23 from
2688+
# https://www.cloudflare.com/ips/ (https://www.cloudflare.com/ips-v4 + /ips-v6).
2689+
# Refresh on release: replace both lists from that source and bump _CLOUDFLARE_IPS_CAPTURED.
2690+
_CLOUDFLARE_IPS_CAPTURED = '2026-07-23'
2691+
_CLOUDFLARE_IPS_V4 = [
2692+
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
2693+
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
2694+
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
2695+
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
2696+
]
2697+
_CLOUDFLARE_IPS_V6 = [
2698+
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
2699+
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
2700+
]
2701+
_PRIVATE_IP_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7']
2702+
2703+
2704+
def _trusted_ip_key(cidr: str) -> str:
2705+
try:
2706+
return str(ipaddress.ip_network(str(cidr).strip(), strict=False))
2707+
except ValueError:
2708+
return str(cidr).strip().lower()
2709+
2710+
2711+
def _is_valid_cidr(cidr: str) -> bool:
2712+
try:
2713+
ipaddress.ip_network(str(cidr).strip(), strict=False)
2714+
return True
2715+
except ValueError:
2716+
return False
2717+
2718+
2719+
def _merge_trusted_ips(existing: list, additions: list) -> tuple:
2720+
seen = {_trusted_ip_key(x) for x in existing}
2721+
added = []
2722+
for cidr in additions:
2723+
key = _trusted_ip_key(cidr)
2724+
if key in seen:
2725+
continue
2726+
seen.add(key)
2727+
added.append(cidr)
2728+
return list(existing) + added, added
2729+
2730+
2731+
def _parse_cidr_input(raw) -> list:
2732+
if isinstance(raw, list):
2733+
parts = [str(x) for x in raw]
2734+
else:
2735+
parts = re.split(r'[\s,]+', str(raw or ''))
2736+
return [p.strip() for p in parts if p.strip()]
2737+
2738+
2739+
@app.route('/api/static/trusted-ips/preview', methods=['POST'])
2740+
@csrf_protect
2741+
@login_required
2742+
def api_static_trusted_ips_preview():
2743+
req = request.get_json(silent=True) or {}
2744+
current_raw = req.get('current_raw', '')
2745+
entrypoint = str(req.get('entrypoint', '')).strip()
2746+
try:
2747+
_y = YAML()
2748+
_y.preserve_quotes = True
2749+
if current_raw:
2750+
config = _y.load(StringIO(current_raw)) or {}
2751+
else:
2752+
path = _get_static_config_path()
2753+
if not path or not os.path.exists(path):
2754+
return jsonify({'error': 'Static config not found'}), 404
2755+
with open(path, 'r') as f:
2756+
config = _y.load(f) or {}
2757+
if not isinstance(config, dict):
2758+
return jsonify({'error': 'Static config is not a mapping'}), 400
2759+
ep_key = 'entryPoints' if 'entryPoints' in config else ('entrypoints' if 'entrypoints' in config else 'entryPoints')
2760+
eps = config.get(ep_key)
2761+
summary = []
2762+
if isinstance(eps, dict):
2763+
for nm, cfg in eps.items():
2764+
cur = []
2765+
addr = ''
2766+
if isinstance(cfg, dict):
2767+
addr = str(cfg.get('address', ''))
2768+
fh = cfg.get('forwardedHeaders')
2769+
if isinstance(fh, dict) and isinstance(fh.get('trustedIPs'), list):
2770+
cur = [str(x) for x in fh['trustedIPs']]
2771+
summary.append({'name': str(nm), 'address': addr, 'trusted_ips': cur})
2772+
resp = {
2773+
'ok': True,
2774+
'entrypoints': summary,
2775+
'cloudflare_captured': _CLOUDFLARE_IPS_CAPTURED,
2776+
'cloudflare_ranges': _CLOUDFLARE_IPS_V4 + _CLOUDFLARE_IPS_V6,
2777+
'private_ranges': _PRIVATE_IP_RANGES,
2778+
}
2779+
if not entrypoint:
2780+
return jsonify(resp)
2781+
if not isinstance(eps, dict) or entrypoint not in eps:
2782+
return jsonify({'error': f'Entrypoint "{entrypoint}" not found in static config'}), 400
2783+
custom = _parse_cidr_input(req.get('custom_cidrs', []))
2784+
invalid = [c for c in custom if not _is_valid_cidr(c)]
2785+
additions = []
2786+
if req.get('cloudflare'):
2787+
additions += _CLOUDFLARE_IPS_V4 + _CLOUDFLARE_IPS_V6
2788+
if req.get('private'):
2789+
additions += _PRIVATE_IP_RANGES
2790+
additions += [c for c in custom if _is_valid_cidr(c)]
2791+
ep = eps.get(entrypoint)
2792+
if not isinstance(ep, dict):
2793+
ep = {}
2794+
fh = ep.get('forwardedHeaders') if isinstance(ep.get('forwardedHeaders'), dict) else {}
2795+
cur_seq = fh.get('trustedIPs')
2796+
existing = [str(x) for x in cur_seq] if isinstance(cur_seq, list) else []
2797+
final, added = _merge_trusted_ips(existing, additions)
2798+
if isinstance(cur_seq, list):
2799+
for c in added:
2800+
cur_seq.append(DoubleQuotedScalarString(c))
2801+
else:
2802+
fh['trustedIPs'] = [DoubleQuotedScalarString(c) for c in final]
2803+
ep['forwardedHeaders'] = fh
2804+
eps[entrypoint] = ep
2805+
stream = StringIO()
2806+
_y.dump(config, stream)
2807+
new_raw = stream.getvalue()
2808+
parsed = SafeYAML(typ='safe').load(new_raw) or {}
2809+
resp.update({
2810+
'entrypoint': entrypoint,
2811+
'existing': existing,
2812+
'added': added,
2813+
'final': final,
2814+
'invalid': invalid,
2815+
'raw': new_raw,
2816+
'parsed': parsed,
2817+
})
2818+
return jsonify(resp)
2819+
except Exception as e:
2820+
logger.exception("Trusted IPs preview failed")
2821+
return jsonify({'error': str(e)}), 500
2822+
2823+
26872824
@app.route('/api/setup/test-connection', methods=['POST'])
26882825
@login_required
26892826
def api_setup_test_connection():

docs/static.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ Multiple edits in one session only require a single restart.
5959

6060
---
6161

62+
## Trusted IPs helper
63+
64+
Behind a proxy such as Cloudflare, Traefik only believes `X-Forwarded-For` from sources listed in an entrypoint's `forwardedHeaders.trustedIPs`. Until those are set, your logs, CrowdSec, `ipAllowList` and the login limiter all see the proxy IP instead of the real client. The **Trusted IPs** button in the Static Config header opens a guided helper that writes that field for you.
65+
66+
1. Pick the target entrypoint (for example `websecure`). Any `trustedIPs` already configured are shown.
67+
2. Choose one or more sources:
68+
- **Cloudflare edge ranges** - the full IPv4 + IPv6 set, hardcoded with a capture date. Nothing is fetched at runtime.
69+
- **Private ranges** - `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`.
70+
- **Your own proxies / LAN** - free-form CIDRs or single IPs, one per line. Invalid entries are flagged and skipped.
71+
3. Click **Preview change** to see exactly which ranges will be added. Existing entries are kept, and anything already trusted is deduplicated - the helper only ever adds.
72+
4. Click **Apply & Save** to stage the change into the static config, back it up, and save. As with any static change, a **Restart required** banner then appears.
73+
74+
Because `trustedIPs` lives in the static config, this is global and needs a Traefik restart. Every trusted range can forge client IPs downstream, so only add proxies you control. Use the [Client IP Diagnostic](hardening.md) to confirm what actually reaches the app before and after. The helper works for the Host and for remote agents.
75+
76+
::: tip Refreshing the Cloudflare ranges
77+
The hardcoded ranges live in `_CLOUDFLARE_IPS_V4` / `_CLOUDFLARE_IPS_V6` in `app.py`, sourced from [cloudflare.com/ips](https://www.cloudflare.com/ips/) (`/ips-v4` + `/ips-v6`). They are refreshed on release; replace both lists from that source and bump `_CLOUDFLARE_IPS_CAPTURED`.
78+
:::
79+
80+
---
81+
6282
## Setup
6383

6484
### 1. Mount traefik.yml into TM

templates/index.html

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,8 @@
424424

425425
{% include 'modals/ip_diagnostic_modal.html' %}
426426

427+
{% include 'modals/trusted_ips_modal.html' %}
428+
427429
{% include 'modals/detail_panels.html' %}
428430

429431
<script>
@@ -6862,6 +6864,7 @@ <h3 class="font-bold text-sm font-mono truncate" style="color:var(--text)" title
68626864
const redir = ep.http?.redirections?.entryPoint?.to || '';
68636865
const uhs = ep.http?.underscoreHeadersStrategy || '';
68646866
const http3 = !!ep.http3;
6867+
const tips = Array.isArray(ep.forwardedHeaders?.trustedIPs) ? ep.forwardedHeaders.trustedIPs.length : 0;
68656868
const port = addr.replace(/^.*:/, '');
68666869
const proto = port === '443' ? 'HTTPS' : port === '80' ? 'HTTP' : port ? port : '';
68676870
const nd = JSON.stringify(name);
@@ -6874,6 +6877,7 @@ <h3 class="font-bold text-sm font-mono truncate" style="color:var(--text)" title
68746877
${redir ? `<span class="text-xs flex-shrink-0" style="color:var(--muted)">→ <span class="font-mono" style="color:var(--text)">${_esc(redir)}</span></span>` : ''}
68756878
${http3 ? `<span class="text-xs px-1.5 py-0.5 rounded font-semibold flex-shrink-0" style="background:rgba(163,113,247,0.1);color:var(--purple)">HTTP/3</span>` : ''}
68766879
${uhs ? `<span class="text-xs px-1.5 py-0.5 rounded font-semibold flex-shrink-0" style="background:rgba(63,185,80,0.12);color:var(--green)" title="underscoreHeadersStrategy: ${_esc(uhs)}"><i class="ph-bold ph-shield-check" style="font-size:10px"></i> ${_esc(uhs)}</span>` : ''}
6880+
${tips ? `<span class="text-xs px-1.5 py-0.5 rounded font-semibold flex-shrink-0" style="background:rgba(36,161,222,0.1);color:var(--blue)" title="forwardedHeaders.trustedIPs: ${tips} range(s)"><i class="ph-bold ph-shield" style="font-size:10px"></i> ${tips} trusted</span>` : ''}
68776881
</div>
68786882
<div class="flex gap-1 flex-shrink-0">
68796883
<button onclick='openStaticEditForm("entrypoints",${nd})' class="btn-icon text-xs" title="Edit"><i class="ph-bold ph-pencil-simple"></i></button>
@@ -7515,6 +7519,120 @@ <h3 class="font-bold text-sm font-mono truncate" style="color:var(--text)" title
75157519
await _loadStaticFromDisk();
75167520
}
75177521

7522+
let _tipData = null;
7523+
7524+
function _tipBaseRaw() {
7525+
return _staticMonaco ? _staticMonaco.getValue() : _staticRawContent;
7526+
}
7527+
7528+
function _tipInvalidatePreview() {
7529+
if (_tipData) _tipData.preview = null;
7530+
const pv = document.getElementById('tipPreviewBox'); if (pv) pv.innerHTML = '';
7531+
const applyBtn = document.getElementById('tipApplyBtn'); if (applyBtn) applyBtn.disabled = true;
7532+
}
7533+
7534+
function openTrustedIpsHelper() {
7535+
const modal = document.getElementById('trustedIpsModal');
7536+
if (!modal) return;
7537+
_tipData = null;
7538+
document.getElementById('tipEntrypoint').innerHTML = '<option value="">Loading...</option>';
7539+
document.getElementById('tipCurrent').innerHTML = '';
7540+
document.getElementById('tipPreviewBox').innerHTML = '';
7541+
document.getElementById('tipCustom').value = '';
7542+
document.getElementById('tipSrcCloudflare').checked = true;
7543+
document.getElementById('tipSrcPrivate').checked = false;
7544+
document.getElementById('tipApplyBtn').disabled = true;
7545+
modal.style.display = 'flex';
7546+
_tipInspect();
7547+
}
7548+
7549+
function closeTrustedIpsModal() {
7550+
const m = document.getElementById('trustedIpsModal');
7551+
if (m) m.style.display = 'none';
7552+
}
7553+
7554+
async function _tipInspect() {
7555+
try {
7556+
const res = await fetch('/api/static/trusted-ips/preview', { method: 'POST', headers: { 'Content-Type': 'application/json', ..._csrfHeaders() }, body: JSON.stringify({ current_raw: _tipBaseRaw() }) });
7557+
const d = await res.json();
7558+
if (!res.ok || d.error) { showToast(d.error || 'Failed to read static config', 'error'); closeTrustedIpsModal(); return; }
7559+
_tipData = d;
7560+
const sel = document.getElementById('tipEntrypoint');
7561+
if (!d.entrypoints.length) {
7562+
sel.innerHTML = '<option value="">No entrypoints found</option>';
7563+
} else {
7564+
sel.innerHTML = d.entrypoints.map(e => `<option value="${_esc(e.name)}">${_esc(e.name)}${e.address ? ' (' + _esc(e.address) + ')' : ''}</option>`).join('');
7565+
}
7566+
const cf = document.getElementById('tipCfLabel');
7567+
if (cf) cf.textContent = `Cloudflare edge ranges (${(d.cloudflare_ranges || []).length}, captured ${d.cloudflare_captured})`;
7568+
_tipRenderCurrent();
7569+
} catch (e) { showToast('Failed to read static config', 'error'); closeTrustedIpsModal(); }
7570+
}
7571+
7572+
function _tipRenderCurrent() {
7573+
if (_tipData) _tipData.preview = null;
7574+
const applyBtn = document.getElementById('tipApplyBtn');
7575+
if (applyBtn) applyBtn.disabled = true;
7576+
const pv = document.getElementById('tipPreviewBox');
7577+
if (pv) pv.innerHTML = '';
7578+
const sel = document.getElementById('tipEntrypoint');
7579+
const name = sel ? sel.value : '';
7580+
const ep = (_tipData && _tipData.entrypoints || []).find(e => e.name === name);
7581+
const box = document.getElementById('tipCurrent');
7582+
if (!box) return;
7583+
const cur = (ep && ep.trusted_ips) || [];
7584+
if (!cur.length) {
7585+
box.innerHTML = `<span class="text-xs" style="color:var(--muted)">No <code class="font-mono">trustedIPs</code> on this entrypoint yet.</span>`;
7586+
} else {
7587+
box.innerHTML = `<div class="text-xs mb-1" style="color:var(--muted)">Current <code class="font-mono">trustedIPs</code> (${cur.length}):</div><div class="flex flex-wrap gap-1">` + cur.map(c => `<span class="text-xs font-mono px-1.5 py-0.5 rounded" style="background:var(--input-bg);color:var(--text)">${_esc(c)}</span>`).join('') + `</div>`;
7588+
}
7589+
}
7590+
7591+
async function tipPreview() {
7592+
const sel = document.getElementById('tipEntrypoint');
7593+
const entrypoint = sel ? sel.value : '';
7594+
if (!entrypoint) { showToast('Pick an entrypoint', 'error'); return; }
7595+
const cloudflare = document.getElementById('tipSrcCloudflare').checked;
7596+
const priv = document.getElementById('tipSrcPrivate').checked;
7597+
const custom = document.getElementById('tipCustom').value;
7598+
if (!cloudflare && !priv && !custom.trim()) { showToast('Select at least one source', 'error'); return; }
7599+
try {
7600+
const res = await fetch('/api/static/trusted-ips/preview', { method: 'POST', headers: { 'Content-Type': 'application/json', ..._csrfHeaders() }, body: JSON.stringify({ current_raw: _tipBaseRaw(), entrypoint, cloudflare, private: priv, custom_cidrs: custom }) });
7601+
const d = await res.json();
7602+
if (!res.ok || d.error) { showToast(d.error || 'Preview failed', 'error'); return; }
7603+
if (_tipData) _tipData.preview = d;
7604+
_tipRenderPreview(d);
7605+
} catch (e) { showToast('Preview failed', 'error'); }
7606+
}
7607+
7608+
function _tipRenderPreview(d) {
7609+
const box = document.getElementById('tipPreviewBox');
7610+
if (!box) return;
7611+
const added = d.added || [], invalid = d.invalid || [], existing = d.existing || [];
7612+
let html = '';
7613+
if (!added.length && !invalid.length) {
7614+
html += `<div class="text-xs px-3 py-2 rounded" style="background:rgba(234,179,8,0.1);color:#ca8a04">Nothing new to add - every selected range is already trusted on <span class="font-mono">${_esc(d.entrypoint)}</span>.</div>`;
7615+
}
7616+
if (added.length) {
7617+
html += `<div class="text-xs mb-1" style="color:var(--green)"><i class="ph-bold ph-plus-circle"></i> Adding ${added.length} range${added.length > 1 ? 's' : ''}:</div><div class="flex flex-wrap gap-1 mb-2">` + added.map(c => `<span class="text-xs font-mono px-1.5 py-0.5 rounded" style="background:rgba(63,185,80,0.12);color:var(--green)">${_esc(c)}</span>`).join('') + `</div>`;
7618+
}
7619+
if (invalid.length) {
7620+
html += `<div class="text-xs mb-1" style="color:var(--red)"><i class="ph-bold ph-warning"></i> Skipped ${invalid.length} invalid entr${invalid.length > 1 ? 'ies' : 'y'}:</div><div class="flex flex-wrap gap-1 mb-2">` + invalid.map(c => `<span class="text-xs font-mono px-1.5 py-0.5 rounded" style="background:rgba(239,68,68,0.12);color:var(--red)">${_esc(c)}</span>`).join('') + `</div>`;
7621+
}
7622+
html += `<div class="text-xs" style="color:var(--muted)">Result: <span style="color:var(--text);font-weight:600">${d.final.length}</span> trusted range${d.final.length !== 1 ? 's' : ''} on <span class="font-mono">${_esc(d.entrypoint)}</span> (was ${existing.length}).</div>`;
7623+
box.innerHTML = html;
7624+
document.getElementById('tipApplyBtn').disabled = !added.length;
7625+
}
7626+
7627+
async function tipApply() {
7628+
const d = _tipData && _tipData.preview;
7629+
if (!d || !d.raw) return;
7630+
_staticRawContent = d.raw;
7631+
if (_staticMonaco) _staticMonaco.setValue(d.raw);
7632+
closeTrustedIpsModal();
7633+
await saveStaticConfig();
7634+
}
7635+
75187636
const _origSetTheme = setTheme;
75197637
setTheme = function(theme) {
75207638
_origSetTheme(theme);

templates/modals/settings_modal.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,10 @@ <h3 class="font-bold text-sm" style="color:var(--text)" id="settingsModalTitle">
736736
<i id="staticHdrAddIcon" class="ph-bold ph-plus text-xs" style="color:var(--blue)"></i>
737737
<span id="staticHdrAddLabel">Entrypoint</span>
738738
</button>
739+
<button onclick="openTrustedIpsHelper()" class="btn-secondary text-xs flex items-center gap-1.5" title="Add trusted proxy IPs to an entrypoint" style="height:28px;padding:0 10px">
740+
<i class="ph-bold ph-shield-check text-xs" style="color:var(--green)"></i>
741+
Trusted IPs
742+
</button>
739743
<button onclick="openStaticYamlPopout()" class="btn-secondary text-xs flex items-center gap-1.5" style="height:28px;padding:0 10px">
740744
<i class="ph-bold ph-code text-xs"></i>
741745
Raw YAML

0 commit comments

Comments
 (0)