<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Analytics</title><link>https://wilhelm.codes/tags/analytics/</link><description>Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Fri, 11 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/tags/analytics/index.xml" rel="self" type="application/rss+xml"/><item><title>The Safest Login Page is the One I Never Published</title><link>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</link><pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/</guid><category>homelab</category><category>umami</category><category>cloudflare</category><category>analytics</category><wc:kind>post</wc:kind><description>I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up Umami in the homelab. The install was the easy part; the instructions are dead simple.
Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&amp;rsquo;t good enough for me. So, I did what I normally do and stubbornly wandered off to my own path.</description><content:encoded><![CDATA[<p>I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up <a href="https://umami.is">Umami</a> in the homelab. The install was the easy part; the instructions are dead simple.</p>
<p>Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn&rsquo;t good enough for <em>me</em>. So, I did what I normally do and stubbornly wandered off to my own path.</p>
<p>Quick bit of context for anyone who hasn&rsquo;t gone down this particular hole. Umami is a self-hosted analytics platform: a small Node app with a PostgreSQL database to maintain persistence sans the cookies and third parties slupring up all the data. You drop one script tag on your site, it posts a little blob of JSON on each page view and you get a dashboard. Couldn&rsquo;t be easier. My install runs in an LXC on a member node of my homelab&rsquo;s Proxmox cluster.</p>
<p>I landed on it for the &ldquo;normal&rdquo; reasons. Cloudflare&rsquo;s Analytics are free but limited, they don&rsquo;t give me precisely what I&rsquo;m looking for and they&rsquo;re a bit too slow for my liking. And, I&rsquo;m not even going to give Google a single thought; they have enough of my data already.</p>
<p>Which leaves the obvious question: how does a script tag on a public website safely and <em>securely</em> reach a container in my house? Without punching a hole in my router?</p>
<h2 id="the-answer-everyone-gives-you">
  <a class="heading-link" href="#the-answer-everyone-gives-you">The answer everyone gives you.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The standard answer is a <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/">Cloudflare Tunnel</a>. A small daemon, <code>cloudflared</code>, runs next to your service and dials <em>out</em> to Cloudflare. No dicking around with portforwarding, firewall rules or opening up your router to the world. Traffic arrives at Cloudflare, goes down the pipe your daemon already opened and lands on your service. It&rsquo;s genuinely lovely and it costs nothing. And since I already use Cloudflare to <em>currently</em> host my static websites and domains it was just the pragmatic option. We&rsquo;ll see how long I&rsquo;m comfortable with this, but moving on&hellip;</p>
<p>The second half of the standard answer is where I nearly went wrong.</p>
<p>Umami has an admin interface. An admin interface typically has a login page. So every guide, quite reasonably, tells you to put something like <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/">Cloudflare Access</a> in front of it. Access is an authentication layer at the edge. Basically, someone hits your hostname, Cloudflare intercepts, they sign in against an identity provider and only then does the request continue to your box.</p>
<p>I had the tab open, I was about to wire it to my IDP aaaand then I actually stopped to read my own ingress config. What followed was a very slow eyebrow raise.</p>
<h2 id="tunnels-match-on-path">
  <a class="heading-link" href="#tunnels-match-on-path">Tunnels match on path.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The important little detail I clumsily skimmed past was that a tunnel&rsquo;s ingress rules do not just match on hostname. They match on <strong>path</strong>.</p>
<p>I had written this, which is the shape everyone starts with:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://umami:3000&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div>
</div>
<p>A single hostname and everything on it goes straight to the app while anything else gets a <code>404</code>. Ok.</p>
<p>But my analytics service only <em>needs</em> to expose <em>two things</em> to the public internet; the tracker script and the collector endpoint. That is the entirety of the desired contract. Nobody on the internet needs to see <code>/login</code> or the dashboard, the settings, the user management or the API the dashboard talks to. So, why was I publishing them and then buying a lock to place in front of it?</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">HCL</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">ingress</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/bundle\\.js$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://umami:3000&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    hostname</span> <span class="o">=</span> <span class="s2">&#34;analytics.nightcity.network&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    path</span>     <span class="o">=</span> <span class="s2">&#34;^/api/v1/data$&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    service</span>  <span class="o">=</span> <span class="s2">&#34;http://umami:3000&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl">  {
</span></span><span class="line"><span class="cl"><span class="n">    service</span> <span class="o">=</span> <span class="s2">&#34;http_status:404&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div>
</div>
<p>I only need precisely <em>two</em> paths out while everything else gets met with a lovely <code>404</code>. Not &ldquo;protected&rdquo; or hidden behind a challenge or login screen; just <em>absent</em>.</p>
<p>My admin interface still exists, of course ( I am not typing SQL to read my own page views ). It sits on my home network behind my own reverse proxy, where it has always been, reachable from the couch and over the VPN I already run for everything else. I did not lose anything. I just stopped handing the entirety of it to the internet and then slapping a gate over it.</p>
<p>Drawn out, the whole arrangement is two roads that happen to share a name:</p>
<figure class="diagram"><svg id="d-2e6e925be553" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 544.164px; background-color: transparent;" viewBox="0 0 544.1640625 555" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#d-2e6e925be553{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-2e6e925be553 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-2e6e925be553 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-2e6e925be553 .error-icon{fill:#202225;}#d-2e6e925be553 .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-2e6e925be553 .edge-thickness-normal{stroke-width:1px;}#d-2e6e925be553 .edge-thickness-thick{stroke-width:3.5px;}#d-2e6e925be553 .edge-pattern-solid{stroke-dasharray:0;}#d-2e6e925be553 .edge-thickness-invisible{stroke-width:0;fill:none;}#d-2e6e925be553 .edge-pattern-dashed{stroke-dasharray:3;}#d-2e6e925be553 .edge-pattern-dotted{stroke-dasharray:2;}#d-2e6e925be553 .marker{fill:#8b8b84;stroke:#8b8b84;}#d-2e6e925be553 .marker.cross{stroke:#8b8b84;}#d-2e6e925be553 svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-2e6e925be553 p{margin:0;}#d-2e6e925be553 .label{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color:#32d74b;}#d-2e6e925be553 .cluster-label text{fill:#e3e3de;}#d-2e6e925be553 .cluster-label span{color:#e3e3de;}#d-2e6e925be553 .cluster-label span p{background-color:transparent;}#d-2e6e925be553 .label text,#d-2e6e925be553 span{fill:#32d74b;color:#32d74b;}#d-2e6e925be553 .node rect,#d-2e6e925be553 .node circle,#d-2e6e925be553 .node ellipse,#d-2e6e925be553 .node polygon,#d-2e6e925be553 .node path{fill:#1e1f22;stroke:#3c3f43;stroke-width:1px;}#d-2e6e925be553 .rough-node .label text,#d-2e6e925be553 .node .label text,#d-2e6e925be553 .image-shape .label,#d-2e6e925be553 .icon-shape .label{text-anchor:middle;}#d-2e6e925be553 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#d-2e6e925be553 .rough-node .label,#d-2e6e925be553 .node .label,#d-2e6e925be553 .image-shape .label,#d-2e6e925be553 .icon-shape .label{text-align:center;}#d-2e6e925be553 .node.clickable{cursor:pointer;}#d-2e6e925be553 .root .anchor path{fill:#8b8b84!important;stroke-width:0;stroke:#8b8b84;}#d-2e6e925be553 .arrowheadPath{fill:#dfddda;}#d-2e6e925be553 .edgePaths .path{stroke:#8b8b84;stroke-width:1px;}#d-2e6e925be553 .flowchart-link{stroke:#8b8b84;fill:none;}#d-2e6e925be553 .edgeLabel{background-color:#1e1f22;text-align:center;}#d-2e6e925be553 .edgeLabel p{background-color:#1e1f22;}#d-2e6e925be553 .edgeLabel rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-2e6e925be553 .labelBkg{background-color:rgba(30, 31, 34, 0.5);}#d-2e6e925be553 .cluster rect{fill:#202225;stroke:#2c2e31;stroke-width:1px;}#d-2e6e925be553 .cluster text{fill:#e3e3de;}#d-2e6e925be553 .cluster span{color:#e3e3de;}#d-2e6e925be553 .node .collapsed-indicator{fill:#2c2e31;stroke:none;opacity:0.6;}#d-2e6e925be553 .node .collapsed-separator{stroke:#2c2e31;stroke-width:0.75px;}#d-2e6e925be553 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;background:#202225;border:1px solid hsl(216, 0%, 23.5294117647%);border-radius:2px;pointer-events:none;z-index:100;}#d-2e6e925be553 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#32d74b;}#d-2e6e925be553 rect.text{fill:none;stroke-width:0;}#d-2e6e925be553 .icon-shape,#d-2e6e925be553 .image-shape{background-color:#1e1f22;text-align:center;}#d-2e6e925be553 .icon-shape p,#d-2e6e925be553 .image-shape p{background-color:#1e1f22;padding:2px;}#d-2e6e925be553 .icon-shape .label rect,#d-2e6e925be553 .image-shape .label rect{opacity:0.5;background-color:#1e1f22;fill:#1e1f22;}#d-2e6e925be553 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#d-2e6e925be553 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#d-2e6e925be553 .node .neo-node{stroke:#3c3f43;}#d-2e6e925be553 [data-look="neo"].node rect,#d-2e6e925be553 [data-look="neo"].cluster rect,#d-2e6e925be553 [data-look="neo"].node polygon{stroke:url(#d-2e6e925be553-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-2e6e925be553 [data-look="neo"].swimlane.cluster rect{filter:none;}#d-2e6e925be553 [data-look="neo"].node path{stroke:url(#d-2e6e925be553-gradient);stroke-width:1px;}#d-2e6e925be553 [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-2e6e925be553 [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-2e6e925be553 [data-look="neo"].node circle{stroke:url(#d-2e6e925be553-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-2e6e925be553 [data-look="neo"].node circle .state-start{fill:#000000;}#d-2e6e925be553 [data-look="neo"].icon-shape .icon{fill:url(#d-2e6e925be553-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-2e6e925be553 [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-2e6e925be553-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-2e6e925be553 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="d-2e6e925be553_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="d-2e6e925be553_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="d-2e6e925be553_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"/><g class="edgePaths"><path d="M264.1,59L274.953,64.917C285.806,70.833,307.512,82.667,318.366,93.833C329.219,105,329.219,115.5,329.219,120.75L329.219,126" id="d-2e6e925be553-L_N_CD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_CD_0" data-points="W3sieCI6MjY0LjA5OTgwMTQ4NTY1NTc1LCJ5Ijo1OX0seyJ4IjozMjkuMjE4NzUsInkiOjk0LjV9LHsieCI6MzI5LjIxODc1LCJ5IjoxMzB9XQ==" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M170.549,59L159.695,64.917C148.842,70.833,127.136,82.667,116.283,98.75C105.43,114.833,105.43,135.167,105.43,155.5C105.43,175.833,105.43,196.167,105.43,211.583C105.43,227,105.43,237.5,105.43,242.75L105.43,248" id="d-2e6e925be553-L_N_TD_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_N_TD_0" data-points="W3sieCI6MTcwLjU0ODYzNjAxNDM0NDI1LCJ5Ijo1OX0seyJ4IjoxMDUuNDI5Njg3NSwieSI6OTQuNX0seyJ4IjoxMDUuNDI5Njg3NSwieSI6MTU1LjV9LHsieCI6MTA1LjQyOTY4NzUsInkiOjIxNi41fSx7IngiOjEwNS40Mjk2ODc1LCJ5IjoyNTJ9XQ==" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M329.219,181L329.219,186.917C329.219,192.833,329.219,204.667,329.219,215.833C329.219,227,329.219,237.5,329.219,242.75L329.219,248" id="d-2e6e925be553-L_CD_E_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_CD_E_0" data-points="W3sieCI6MzI5LjIxODc1LCJ5IjoxODF9LHsieCI6MzI5LjIxODc1LCJ5IjoyMTYuNX0seyJ4IjozMjkuMjE4NzUsInkiOjI1Mn1d" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M105.43,303L105.43,308.917C105.43,314.833,105.43,326.667,105.43,337.833C105.43,349,105.43,359.5,105.43,364.75L105.43,370" id="d-2e6e925be553-L_TD_P_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_TD_P_0" data-points="W3sieCI6MTA1LjQyOTY4NzUsInkiOjMwM30seyJ4IjoxMDUuNDI5Njg3NSwieSI6MzM4LjV9LHsieCI6MTA1LjQyOTY4NzUsInkiOjM3NH1d" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M329.219,303L329.219,308.917C329.219,314.833,329.219,326.667,329.219,337.833C329.219,349,329.219,359.5,329.219,364.75L329.219,370" id="d-2e6e925be553-L_E_C_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_E_C_0" data-points="W3sieCI6MzI5LjIxODc1LCJ5IjozMDN9LHsieCI6MzI5LjIxODc1LCJ5IjozMzguNX0seyJ4IjozMjkuMjE4NzUsInkiOjM3NH1d" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M333.056,425L333.947,430.917C334.837,436.833,336.618,448.667,332.494,460.01C328.369,471.354,318.34,482.208,313.326,487.635L308.311,493.062" id="d-2e6e925be553-L_C_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_U_0" data-points="W3sieCI6MzMzLjA1NjE2MDM0ODM2MDY2LCJ5Ijo0MjV9LHsieCI6MzM4LjM5ODQzNzUsInkiOjQ2MC41fSx7IngiOjMwNS41OTY4NTU3ODg5MzQ0LCJ5Ijo0OTZ9XQ==" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M285.933,425L275.889,430.917C265.846,436.833,245.759,448.667,240.73,460.01C235.701,471.354,245.73,482.208,250.744,487.635L255.759,493.062" id="d-2e6e925be553-L_C_U_2" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_U_2" data-points="W3sieCI6Mjg1LjkzMjc2MTI3MDQ5MTgsInkiOjQyNX0seyJ4IjoyMjUuNjcxODc1LCJ5Ijo0NjAuNX0seyJ4IjoyNTguNDczNDU2NzExMDY1NiwieSI6NDk2fV0=" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M389.301,425L403.242,430.917C417.183,436.833,445.064,448.667,459.005,459.833C472.945,471,472.945,481.5,472.945,486.75L472.945,492" id="d-2e6e925be553-L_C_F_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_C_F_0" data-points="W3sieCI6Mzg5LjMwMTE2NTQ3MTMxMTUsInkiOjQyNX0seyJ4Ijo0NzIuOTQ1MzEyNSwieSI6NDYwLjV9LHsieCI6NDcyLjk0NTMxMjUsInkiOjQ5Nn1d" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/><path d="M105.43,425L105.43,430.917C105.43,436.833,105.43,448.667,125.721,461.592C146.012,474.517,186.594,488.534,206.885,495.543L227.176,502.552" id="d-2e6e925be553-L_P_U_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_P_U_0" data-points="W3sieCI6MTA1LjQyOTY4NzUsInkiOjQyNX0seyJ4IjoxMDUuNDI5Njg3NSwieSI6NDYwLjV9LHsieCI6MjMwLjk1NzAzMTI1LCJ5Ijo1MDMuODU3NDc5Mzc0NDg4NX1d" data-look="classic" marker-end="url(#d-2e6e925be553_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(329.21875, 94.5)"><g class="label" data-id="L_N_CD_0" transform="translate(-96.9375, -10.5)"><foreignObject width="193.875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from the internet</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(105.4296875, 155.5)"><g class="label" data-id="L_N_TD_0" transform="translate(-71.6484375, -10.5)"><foreignObject width="143.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>asked from my LAN</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(329.21875, 216.5)"><g class="label" data-id="L_CD_E_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>104.21.x.x</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(105.4296875, 338.5)"><g class="label" data-id="L_TD_P_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>10.0.0.200</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_E_C_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(334.17917, 465.06637)"><g class="label" data-id="L_C_U_0" transform="translate(-42.1484375, -10.5)"><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/bundle.js</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(234.97979, 455.01666)"><g class="label" data-id="L_C_U_2" transform="translate(-50.578125, -10.5)"><foreignObject width="101.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>/api/v1/data</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(472.9453125, 460.5)"><g class="label" data-id="L_C_F_0" transform="translate(-63.21875, -10.5)"><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>everything else</p></span></div></foreignObject></g></g><g class="edgeLabel"><g class="label" data-id="L_P_U_0" transform="translate(0, 0)"><foreignObject width="0" height="0"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default" id="d-2e6e925be553-flowchart-N-0" data-look="classic" transform="translate(217.32421875, 33.5)"><rect class="basic label-container" style="" x="-143.7890625" y="-25.5" width="287.578125" height="51"/><g class="label" style="" transform="translate(-113.7890625, -10.5)"><rect/><foreignObject width="227.578125" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table; white-space: break-spaces; line-height: 1.5; max-width: 200px; text-align: center; width: 200px;"><span class="nodeLabel"><p>analytics.nightcity.network</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-CD-2" data-look="classic" transform="translate(329.21875, 155.5)"><rect class="basic label-container" style="" x="-89.0078125" y="-25.5" width="178.015625" height="51"/><g class="label" style="" transform="translate(-59.0078125, -10.5)"><rect/><foreignObject width="118.015625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare DNS</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-TD-4" data-look="classic" transform="translate(105.4296875, 277.5)"><rect class="basic label-container" style="" x="-72.1484375" y="-25.5" width="144.296875" height="51"/><g class="label" style="" transform="translate(-42.1484375, -10.5)"><rect/><foreignObject width="84.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Technitium</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-E-6" data-look="classic" transform="translate(329.21875, 277.5)"><rect class="basic label-container" style="" x="-93.21875" y="-25.5" width="186.4375" height="51"/><g class="label" style="" transform="translate(-63.21875, -10.5)"><rect/><foreignObject width="126.4375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Cloudflare edge</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-P-8" data-look="classic" transform="translate(105.4296875, 399.5)"><rect class="basic label-container" style="" x="-97.4296875" y="-25.5" width="194.859375" height="51"/><g class="label" style="" transform="translate(-67.4296875, -10.5)"><rect/><foreignObject width="134.859375" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>My reverse proxy</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-C-10" data-look="classic" transform="translate(329.21875, 399.5)"><rect class="basic label-container" style="" x="-76.359375" y="-25.5" width="152.71875" height="51"/><g class="label" style="" transform="translate(-46.359375, -10.5)"><rect/><foreignObject width="92.71875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>cloudflared</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-U-12" data-look="classic" transform="translate(282.03515625, 521.5)"><rect class="basic label-container" style="" x="-51.078125" y="-25.5" width="102.15625" height="51"/><g class="label" style="" transform="translate(-21.078125, -10.5)"><rect/><foreignObject width="42.15625" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Umami</p></span></div></foreignObject></g></g><g class="node default" id="d-2e6e925be553-flowchart-F-16" data-look="classic" transform="translate(472.9453125, 521.5)"><rect class="basic label-container" style="" x="-42.6484375" y="-25.5" width="85.296875" height="51"/><g class="label" style="" transform="translate(-12.6484375, -10.5)"><rect/><foreignObject width="25.296875" height="21"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>404</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="d-2e6e925be553-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="d-2e6e925be553-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><linearGradient id="d-2e6e925be553-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#3c3f43" stop-opacity="1"/><stop offset="100%" stop-color="hsl(216, 0%, 23.5294117647%)" stop-opacity="1"/></linearGradient></svg></figure><p><code>cloudflared</code> is the one deciding what gets through and it&rsquo;s only aware of two paths. My side of the picture never touches Cloudflare, or the rest of the internet, at all. The fork at the very top is split horizon DNS: one name, two answers, depending on where you ask from. Hold that thought, because it comes back to bite me later.</p>
<h2 id="why-i-think-this-is-the-better-trade">
  <a class="heading-link" href="#why-i-think-this-is-the-better-trade">Why I think this is the better trade.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I want to be fair to Access here, because it&rsquo;s a good product and there are plenty of setups where it&rsquo;s the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don&rsquo;t control, without a VPN, Access is exactly the kind of tool that&rsquo;ll get you there.</p>
<p>But if you don&rsquo;t need that, compare what you&rsquo;re actually buying.</p>
<p>With Access, <code>/login</code> is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the surface is still there. There&rsquo;s an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn&rsquo;t consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.</p>
<p>With path scoping, <code>/login</code> returns a <code>404</code>. There is no flow to misconfigure because there is no flow. There is no IDP to keep alive and manage. There is nothing to leave accidentally open when you change something else eighteen months from now.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>Elimination is the ultimate form of hardening.</p>
  </blockquote>
  <figcaption>
    <cite>Pretty much every cybersecurity professional.</cite>
  </figcaption>
</figure>

<p>The thing I keep coming back to: <strong>an attack surface you removed cannot be misconfigured later.</strong> A control you added can. This is all about shrinking the <a href="https://en.wikipedia.org/wiki/Attack_surface">attack surface</a>, which is a far older idea than my Saturday afternoon. We&rsquo;re not doing anything new here. But a standard installation path is built to get the typical user running quickly and quick doesn&rsquo;t necessarily mean appropriate for a setup like this.</p>
<p>There&rsquo;s a small bonus, too. The default Umami tracker lives at <code>/script.js</code> and posts to <code>/api/send</code> and those exact strings are what content blockers match on. You can rename both with a config value, but because I only published the renamed paths, the default ones now <code>404</code> for the whole internet as a side effect. I didn&rsquo;t plan that, but I&rsquo;ll take it.</p>
<p>If you want a sense of how indiscriminate that matching is: my own resolver blocks <code>umami.is</code>. Not the tracker endpoint. The homepage. Of the analytics tool I chose, installed and run myself. I found out while trying to link to it from this post and that link up there still doesn&rsquo;t work from my desk. Because I&rsquo;m lazy and haven&rsquo;t updated by Technitium blocklists yet.</p>
<h2 id="does-it-work-though">
  <a class="heading-link" href="#does-it-work-though">Does it work though?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The nice part about this setup is that you can test the public path without leaving the house. <code>curl --resolve</code> lets you skip your own DNS and dial the Cloudflare edge directly, while still sending the right hostname, so the request takes the same road a real visitor does.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Bash</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI --resolve analytics.nightcity.network:443:104.21.x.x <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://analytics.nightcity.network/login <span class="p">|</span> head -1</span></span></code></pre></div>
</div>
<p>Run that against a handful of paths and you get:</p>
<div class="code-block">
  <pre tabindex="0"><code>/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404</code></pre>
</div>
<p>That <code>405</code> is the collecter endpoint telling me it only accepts <code>POST</code>, which is exactly what I want to see from a <code>GET</code>. It does admit the endpoint exists, where a <code>404</code> would not, but that one has to be findable anyway. It&rsquo;s sitting in the script on every page. Everything else gets a polite middle finger.</p>
<p>Meanwhile, from inside the house, the same hostname gives me the full dashboard. The same two roads as before and the split falls out of the DNS I already run for network-wide adblock via a Technitium cluster.</p>
<h3 id="something-worth-pointing-out">
  <a class="heading-link" href="#something-worth-pointing-out">Something worth pointing out.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I flipped the ingress config, re-ran my checks and <code>/script.js</code> came back <strong>200</strong>. Which was alarming, because I had just watched it <code>404</code>.</p>
<p>It was cached. Umami serves its tracker with a 24 hour cache header. My earlier testing had pulled it through the edge and Cloudflare was dutifully serving me the copy it already had. Which, to be fair, is exactly what it should normally be doing. A request with a junk query string came back <code>404</code> immediately, which confirmed the origin was doing its job and the edge was just being helpful at the worst possible moment.</p>
<p>Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation is my passion.</p>
<h2 id="hark-a-plot-twist">
  <a class="heading-link" href="#hark-a-plot-twist">Hark! A plot twist!<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I shipped the script tag, confirmed real traffic was landing, felt very clever about the whole thing and then Firefox showed me this:</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: The Firefox popup for Local Network Access.">
      <img
  src="https://wilhelm.codes/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp"
  srcset="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_cb80f6f98d090c39.webp 736w, /blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_74200a685c603811.webp 1104w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="308"
  alt="The Firefox popup for Local Network Access."
  loading="lazy"
  decoding="async"
  data-full="/blog/the-safest-login-page-is-the-one-i-never-published/local-network-access-popup_hu_53a1cc4f5ba18d12.webp"
  data-credit="Go home Firefox, you are drunk."
  
/>

    </button>
    
      <figcaption>
        
          Go home Firefox, you are drunk.
        
      </figcaption>
    
  </figure>
<p>Sir, this is my own site from my machine on my home network. How <em>dare</em> you?</p>
<p>So, I run split horizon DNS. Inside my network, <code>analytics.nightcity.network</code> resolves to a private address on my LAN so I get the dashboard directly. Outside, where <em>you</em> are reading from, the same name resolves to Cloudflare and goes down the tunnel. That&rsquo;s what makes the two-faced thing work. I mentioned this before in my previous article <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a> and it&rsquo;s one of my favourite aspects about my setup.</p>
<p>But when I load my <em>public</em> blog from my <em>own house</em>, the browser sees a page served from a public origin trying to load a subresource from <code>10.x.x.x</code>. Browsers have started treating that as exactly the attack it usually is: a website on the internet quietly poking at your router, your printer or your NAS. Firefox calls it <a href="https://support.mozilla.org/en-US/kb/control-personal-device-local-network-permissions-firefox">Local Network Access</a> and blocks it by default. Chrome is heading the same way.</p>
<p>So the one place on earth where my analytics silently do not work is the chair I&rsquo;m sitting in.</p>
<p>I sat with that for a minute before realising it&rsquo;s <em>correct behaviour on every level</em>. The browser is right to block it. My DNS is right to answer the way it does. And analytics that ignore my own visits are analytics that aren&rsquo;t polluted by me hammering refresh on my own blog. This is effectively all three of these systems disagreeing correctly.</p>
<p>If it ever bothers me, the fix is to give the tracker its own hostname in a domain my internal resolver doesn&rsquo;t answer for, so it goes out to the edge from everywhere including my lounge room. I have not bothered. I&rsquo;m all too happy to skip my own page views.</p>
<h2 id="the-one-thing-path-scoping-does-not-fix">
  <a class="heading-link" href="#the-one-thing-path-scoping-does-not-fix">The one thing path scoping does not fix.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>There is one limitation here and I&rsquo;d rather name it than let you find it on your own.</p>
<p>Moving the admin interface off the internet does nothing for the collecter endpoint. It can&rsquo;t. That endpoint has to accept anonymous <code>POST</code>s from every visitor&rsquo;s browser, or it isn&rsquo;t an analytics endpoint. Umami rejects requests carrying a website ID it doesn&rsquo;t recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every post I&rsquo;ve ever written. Literally anyone can read it.</p>
<p>So the only real control is applying some kind of rate limit. I&rsquo;ll admit up front this is more of a speed bump rather than a wall. Cloudflare&rsquo;s free tier, which I&rsquo;m currently on because I&rsquo;m cheap and actually trying to <em>reduce</em> my subscription costs, permits one rule at a ten second window and it insists on counting per <em>data centre</em> rather than <em>globally</em>. It&rsquo;ll stop something stupid. But, it will not stop something determined.</p>
<p>The honest summary is that path scoping shrinks the surface enormously and then stops. All that&rsquo;s left is the stuff that&rsquo;s <em>supposed</em> to be open.</p>
<p>We&rsquo;ll see how this goes, but worst case I can always stick Nginx between Umami and <code>cloudflared</code> with something like the following:</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">Nginx</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">...</span> <span class="s">boilerplate</span> <span class="s">...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="s">location</span> <span class="p">=</span> <span class="s">/api/v1/data</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">limit_req</span>        <span class="s">zone=collect</span> <span class="s">burst=20</span> <span class="s">nodelay</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">limit_req_status</span> <span class="mi">429</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_pass</span>       <span class="s">http://umami:3000</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">...</span> <span class="s">more</span> <span class="s">boilerplate</span> <span class="s">...</span></span></span></code></pre></div>
</div>
<p>Running out of disk space with garbage data points would really be the worst case scenario here. Which would no-shit make me laugh.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>None of this is Cloudflare-specific either, by the way. Any reverse proxy has a path-scoped location block and any tunnel worth the name matches on more than a hostname. The vendor is incidental. If you want to use Tailscale, Pangoline or Netbird, then go for your life. The idea is just to publish only what <em>has</em> to be public.</p>
<p>None of this is novel. Tunnels have matched on path since forever, it&rsquo;s in the <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">docs</a> and I&rsquo;m certain plenty of people are already doing exactly this. I just hadn&rsquo;t thought about it properly, because the well-trodden advice is &ldquo;put an auth layer in front of it&rdquo; and this kind of consensus is usually good enough that you stop thinking.</p>
<p>But &ldquo;protect the admin panel&rdquo; and &ldquo;publish the admin panel and then protect it&rdquo; are not the same sentence and you should be aware of the difference.</p>
<p>If you&rsquo;re running anything through a tunnel right now, it&rsquo;s worth five minutes with your ingress config. Ask yourself which paths actually need to leave your network. For a lot of self-hosted things the honest answer is &ldquo;fewer than all of them&rdquo;. The best answer is &ldquo;none&rdquo;.</p>
<p>And if you&rsquo;d rather find out than wonder, that <code>curl --resolve</code> trick works against any hostname, including yours. Start with <code>/login</code> and see what comes back.</p>
<p>And if you&rsquo;re about to tell me the browser permission dialog is a bug, it isn&rsquo;t. It&rsquo;s three correct systems arguing and I&rsquo;ve decided that&rsquo;s a feature. So, there.</p>
<h2 id="if-you-want-to-pull-on-any-of-these-threads-">
  <a class="heading-link" href="#if-you-want-to-pull-on-any-of-these-threads-">If you want to pull on any of these threads &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>I kept the theory light in the post, because naming a principle tends to make a small idea sound like it needs the backup and it kind of goes against the vibe of the site and my own casual style of writing. But the ideas underneath this are old and much better argued elsewhere, so here&rsquo;s where I&rsquo;d start.</p>
<ul>
<li><a href="https://web.mit.edu/Saltzer/www/publications/protection/">The Protection of Information in Computer Systems</a>, Saltzer and Schroeder, 1975. The source of <em>fail-safe defaults</em> ( deny by default, allow by exception, which is exactly what that catch-all <code>404</code> rule is ) and <em>economy of mechanism</em> ( fewer moving parts means fewer parts to get wrong ). It&rsquo;s a fifty year old paper and it reads like one, but section I is short and it has aged as disgracefully as I have.</li>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Attack_Surface_Analysis_Cheat_Sheet.html">Attack Surface Analysis</a>, OWASP. The plain-English version of the whole post, minus my anecdotes. Useful if you want a structured way to ask &ldquo;what am I actually exposing&rdquo; about something larger than a script tag.</li>
<li><a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/configuration-file/#how-traffic-is-matched">Tunnel configuration file</a>, Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that <code>path</code> was sitting right there next to <code>hostname</code>.</li>
<li><a href="https://wicg.github.io/local-network-access/">Local Network Access</a>, WICG. The spec behind the permission dialog that ambushed me. If you&rsquo;d rather have prose than a spec, Chrome&rsquo;s <a href="https://developer.chrome.com/blog/local-network-access">New permission prompt for Local Network Access</a> covers the same ground and explains why browsers decided this needed fixing.</li>
</ul>
<p>If you only read one, make it the Saltzer and Schroeder. Almost everything I thought I worked out that evening turns out to be in there, described better, by people who got to it first and who are <em>far</em> smarter than me.</p>]]></content:encoded></item></channel></rss>