<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>leodeng.dev</title>
    <link>https://leodeng.dev/blog</link>
    <description>Leo Deng's blog: self-hosting, IoT, and building things.</description>
    <language>en</language>
  <item>
    <title>Made a miniplayer for Apple Music on MacOS</title>
    <link>https://leodeng.dev/blog/made-a-miniplayer-for-apple-music-on-macos</link>
    <guid>https://leodeng.dev/blog/made-a-miniplayer-for-apple-music-on-macos</guid>
    <pubDate>Mon, 10 Aug 2026 13:07:00 GMT</pubDate>
    <description>I Thought Apple Music's overlay is weird on macos, So I made my own version of the MiniPlayer</description>
    <content:encoded><![CDATA[<h2>The Issue</h2>
<p>Recently, I have been using my Mac a lot and noticed a issue: Apple Music&#39;s inbuilt MiniPlayer does not display over apps, it only displays on Desktop. </p>
<p>This is frustrating a lot of times because I don&#39;t want to swipe the screen/click the menu bar &quot;What&#39;s Playing&quot; button to see the song name/artist and progressbar of the music.</p>
<h2>Brainstorming</h2>
<p>I first thought why not use my earlier Ubersicht cd player widget for displaying over other full screen apps, but found that it had the same issue as the Apple Music MiniPlayer, it ONLY displays on desktop.</p>
<p>So BACK to square zero...</p>
<p>After 5 mins:💡 Ah-ha! </p>
<p>Why not vibe code an app in swift, that way it is more customizable!</p>
<h2>Solving the Issue</h2>
<p>After I had the framework, it was easy down the road, I gave an agent my prompt, and with my small tweaks &amp; new functions (Theme Color/Liquid Glass/MacOS 11 through 26 support... and more), it made a miniplayer in ~40min </p>
<p>Then it was done! A full-fledged miniplayer that shows even when your desktop is in fullscreen mode. 😉😉</p>
<p>You can download it <a href="https://github.com/leodenglovescode/MacMusicOverlay/releases">here</a>.</p>
<h2>Some Screenshots</h2>
<p><img src="https://media.leodeng.dev/macmusicoverlay-style-1-new.png" alt="macmusicoverlay style 1 new">
<img src="https://media.leodeng.dev/macmusicoverlay-style-2.png" alt="macmusicoverlay style 2">
<img src="https://media.leodeng.dev/macmusicoverlay-settings-1.png" alt="macmusicoverlay settings 1">
<img src="https://media.leodeng.dev/macmusicoverlay-settings-2.png" alt="macmusicoverlay settings 2"></p>
]]></content:encoded>
  </item>
  <item>
    <title>Explaining my weird home network &amp; services setup</title>
    <link>https://leodeng.dev/blog/explaining-my-weird-home-network-services-setup</link>
    <guid>https://leodeng.dev/blog/explaining-my-weird-home-network-services-setup</guid>
    <pubDate>Thu, 06 Aug 2026 16:30:00 GMT</pubDate>
    <description>Small peek at my setup as a sysadmin :)</description>
    <content:encoded><![CDATA[<h2>About My Network Setup...</h2>
<p>So... Yeah I admit it. My home network is WEIRD, partly because I&#39;m literally 16 years old, partly because I&#39;m broke. But also it&#39;s weird in a good way (kinda), the network is sort of like an intricate web that connects all my important devices together, and that delicacy is held together by LAN cables that are either under the carpet or on the floor , which, if you think about it, is a terrible choice for a sysadmin.</p>
<h2>Diagrams... Fun!</h2>
<p>But anyways, lets get to the actual structure of my LAN network by showing you this mermaid diagram:</p>
<div class="mermaid-block" data-state="pending"><pre class="mermaid-source"><code>
	%%{init: {"flowchart": {"useMaxWidth": true}}}%%
	flowchart TD

    ONT["ONT (Optical Network Terminal)&lt;br/&gt;Bridge Mode"]
    BE3["Main Router"]

    ONT --&gt; BE3

    %% Second Floor
    SW2F["Dumb LAN Switch&lt;br/&gt;2nd Floor&lt;br/&gt;1000 Mbps"]

    NAS["NAS"]
    SERVER["Main Server"]
    Q2["Router #2&lt;br/&gt;(Acts as Wi-Fi Extender)"]
    SW2F100["Dumb Switch&lt;br/&gt;100 Mbps"]

    BMC["Main Server BMC"]
    PRINTER["Printer"]

    BE3 --&gt;|1 Gbps| SW2F

    SW2F --&gt; NAS
    SW2F --&gt; SERVER
    SW2F --&gt; Q2

    Q2 --&gt;|100 Mbps| SW2F100
    SW2F100 --&gt; BMC
    SW2F100 --&gt; PRINTER

    %% First Floor
    SW1F["Dumb LAN Switch&lt;br/&gt;1st Floor&lt;br/&gt;1000 Mbps"]

    WINPC["PC"]
    BOSE["Central Speaker"]
    HA["Home Assistant Server"]
    SW1F100["Dumb LAN Switch&lt;br/&gt;100 Mbps"]

    DOORCAM["IP Camera"]
    LRCAM["IP Camera"]

    BE3 --&gt;|1 Gbps| SW1F

    SW1F --&gt; WINPC
    SW1F --&gt; BOSE
    SW1F --&gt; HA
    SW1F --&gt;|100 Mbps| SW1F100

    SW1F100 --&gt; DOORCAM
    SW1F100 --&gt; LRCAM

    %% Styling
    classDef wan fill:#6c5ce7,stroke:#a29bfe,color:#fff,stroke-width:2px
    classDef router fill:#0984e3,stroke:#74b9ff,color:#fff,stroke-width:2px
    classDef switch fill:#e17055,stroke:#fab1a0,color:#fff,stroke-width:2px
    classDef server fill:#00b894,stroke:#55efc4,color:#fff,stroke-width:2px
    classDef endpoint fill:#636e72,stroke:#b2bec3,color:#fff
    classDef camera fill:#d63031,stroke:#ff7675,color:#fff

    class ONT wan
    class BE3,Q2 router
    class SW2F,SW2F100,SW1F,SW1F100 switch
    class NAS,SERVER,BMC,HA server
    class WINPC,BOSE,PRINTER endpoint
    class DOORCAM,LRCAM camera</code></pre></div><p>And I can pretty much say that this setup works great! (though there is a bit of a spaghetti cable situation that needs action)</p>
<h2>Thoughts on switches</h2>
<p>I initially thought those dumb and cheap VLAN switches would SERIOUSLY decapacitate my network&#39;s speed, but it turns out they remained basically the same 1gbps throughput up AND down, which is surprising considering these switches (5 port, 4 port usable), are around 70 CNY (10.37 USD) a piece.</p>
<p>So I began wondering how this works, then I found it: cheap VLAN switches don’t lose much speed because packet forwarding is handled by dedicated switching hardware, not a slow general-purpose CPU. VLAN tagging adds only a tiny amount of extra data, and modern switch chips can process it at full line rate. The real bottleneck is usually the Ethernet port itself, so thats why I lost no speed!</p>
<p>Now THAT&#39;S what I call a bang for the buck.</p>
<h1>Self-hosted services I am running...</h1>
<h3>Main Server Services:</h3>
<p>NGINX - Load balancer and webserver in front of my website backend</p>
<p>Grafana - Server/Website traffic Monitoring</p>
<p>Immich - Photo gallery</p>
<p>Jellyfin - Media indexer &amp; Player</p>
<p>Scriberr - Audio/Video transcription (Speech-To-Text)</p>
<p>CarbonPanel - Custom built real-time server monitoring panel</p>
<p>MCSManager - Manager for my minecraft server</p>
<h2>Thinkpad E450 Services (Yes its a repurposed old laptop lol, still works great.)</h2>
<p>Home Assistant - Home Controls &amp; Monitoring</p>
<p><br/><br/></p>
<p>Right that&#39;s about it for this blog, thanks again for reading to the very end and see ya next time!</p>
]]></content:encoded>
  </item>
  <item>
    <title>I Forked Tailscale's Android App So My Phone Could Find My Homelab</title>
    <link>https://leodeng.dev/blog/i-forked-tailscales-android-app-so-my-phone-could-find-my-homelab</link>
    <guid>https://leodeng.dev/blog/i-forked-tailscales-android-app-so-my-phone-could-find-my-homelab</guid>
    <pubDate>Wed, 05 Aug 2026 15:57:00 GMT</pubDate>
    <description>Every single thing that broke while building a custom Android client for my self-hosted VPN: a home address I refused to publish, a build that lied to me, a typo on my server that did nothing for weeks, and a bug that only appeared when I left the house.</description>
    <content:encoded><![CDATA[<p>Last time I wrote about getting Headscale running so my Mac, my PC and my phone could all reach my homelab and servers from anywhere. That post ended with everything working. This post is about the part where I looked at the working setup and went &quot;yeah but I don&#39;t like one thing about it,&quot; and then spent weeks fixing that one thing.</p>
<p>Fair warning, this one gets deep. But every single problem in here cost me real time, so I&#39;m writing all of them down.</p>
<p>Also, upfront: I used AI heavily for this. Reading logs, narrowing down causes, remembering API names I&#39;d never heard of. I&#39;ll talk about where that actually helped and where it very much did not near the end.</p>
<h2>The one thing I didn&#39;t like</h2>
<p>My home network gets a public IPv6 address, and it rotates. So for my phone to reach home, that address has to be <em>somewhere</em> my phone can look it up. The normal answer is dynamic DNS: point a hostname at your home IP, update it whenever it changes, done.</p>
<p>Except that means my home address is sitting in public DNS. Anyone who knows the hostname knows exactly where I live, network-wise, and can start knocking. I really didn&#39;t want that.</p>
<p>So I did something slightly cursed. My VPN control server&#39;s hostname resolves to a <strong>private LAN address</strong>. <code>192.168.x.x</code>. That&#39;s the only thing in public DNS. At home, it just works. Away from home, it obviously doesn&#39;t, the connection dies instantly.</p>
<p>The plan: when the connection dies, the app quietly asks a tiny endpoint of mine for the current public IPv6, and dials <em>that</em> instead. The endpoint sits behind a CDN, needs a secret header, and needs a client certificate. So the real address only ever gets handed to something that can prove it&#39;s me.</p>
<p>Which is a lovely plan except no VPN client on earth does that. So, fork.</p>
<h2>Part 1: making the official Android app do something it wasn&#39;t built for</h2>
<p>I forked the open-source Tailscale Android client and called mine <strong>Headlink</strong>.</p>
<h3>Problem 1: I couldn&#39;t actually edit the code I needed to edit</h3>
<p>The networking core isn&#39;t in the app&#39;s repo. It&#39;s a dependency, pinned by version, no local override, no vendored copy. So &quot;just add an if statement in the dialer&quot; was not available to me.</p>
<p>I ended up finding one function where the app <em>hands its own dialer to</em> the core, and swapping in a wrapped one. My entire hook into the networking stack is basically a single statement, and everything else is new files that don&#39;t touch upstream at all.</p>
<p>That sounds like a limitation but it turned out to be the best thing about the whole project. When I rebase onto a newer upstream, there&#39;s almost nothing to conflict.</p>
<h3>Problem 2: not accidentally destroying my own TLS</h3>
<p>This was the part I was genuinely nervous about. If I&#39;m substituting a different IP address, what stops the certificate check from breaking? Or worse, what stops me from &quot;fixing&quot; it by turning verification off, which is the classic way people end up with a VPN that isn&#39;t private at all?</p>
<p>The thing that saved me is where the substitution happens. DNS resolution and TLS setup happen <em>above</em> the dial function. My hook gets called at the very bottom, and all it does is return a socket. It never sees the hostname&#39;s role as an identity, it can&#39;t set the server name, it can&#39;t touch the certificate check. The hostname stays the hostname for SNI, for cert verification, and for the HTTP Host header. Only the physical destination changes.</p>
<p>I wrote a test whose entire job is to fail if that ever stops being true. If someone (me, in six months, tired) tries to make the hook &quot;smarter&quot;, that test breaks.</p>
<h3>Problem 3: the app was sending logs to someone else&#39;s servers</h3>
<p>Upstream ships with telemetry pointed at the vendor&#39;s log service, on by default. For an app whose entire reason to exist is hiding my home address, that&#39;s not okay.</p>
<p>I turned it off twice, on purpose:</p>
<ol>
<li>The config flag that disables it.</li>
<li>A custom HTTP transport that fails every request without even opening a socket.</li>
</ol>
<p>Two independent mechanisms, because the first one is <em>a setting</em>, and settings can get flipped. I also deleted the switch in the UI, because a toggle that can&#39;t change anything is worse than no toggle.</p>
<p>One detail I got wrong first and had to fix: my transport originally returned a fake &quot;200 OK&quot; to keep things quiet. Turns out the logging library <strong>deletes its buffered lines once a POST succeeds.</strong> So faking success would have been silently throwing away my own on-device logs, which are the only logs I have. It has to return an actual error.</p>
<h3>Problem 4: a DNS fallback that defeated the whole point</h3>
<p>Upstream defaults to falling back to Google&#39;s public resolvers when the system DNS is being weird. Reasonable default for most people! Terrible default for an app built specifically to keep my home network&#39;s addressing private. Flipped to off.</p>
<h3>Problem 5: where do I put the secret</h3>
<p>The endpoint needs a shared secret and a client certificate. Those cannot live in plain app preferences, that&#39;s just a file any rooted process can read.</p>
<p>So: everything gets encrypted with an AES-256-GCM key that lives in the Android hardware keystore, and only ever exists as ciphertext on disk. On top of that:</p>
<ul>
<li>The <code>toString()</code> of every config object prints <code>&lt;redacted&gt;</code>. Which sounds paranoid until you realize a crash log prints objects.</li>
<li>Redirects on the lookup request are <strong>refused</strong>, not followed. Otherwise someone could redirect my request somewhere else and my secret goes with it.</li>
<li>The discovered IPv6 address is <strong>never logged</strong>. It&#39;s the one value the entire project exists to protect. Logging it would be genuinely funny in a bad way.</li>
</ul>
<h3>Problem 6: the failure that doesn&#39;t look like a failure</h3>
<p>My lookup endpoint requires a client certificate, checked at the CDN edge. So I tested it with <code>curl</code> and got... nothing. Connection just closed. Exit code 52 or 56, sometimes a weird HTTP framing error. Never a status code, never a &quot;403 forbidden,&quot; nothing that says &quot;auth problem.&quot;</p>
<p>Here&#39;s why, and it&#39;s such a good detail: in TLS 1.3, the handshake <strong>completes before</strong> the server evaluates your client certificate. So from your side, the connection succeeded. Then it just dies. It looks exactly like a broken server, and nothing like being rejected.</p>
<p>I spent way too long assuming my endpoint was down.</p>
<h3>Problem 7: macOS <code>curl</code> is not the <code>curl</code> you think it is</h3>
<p>Once I knew I needed a client cert, I passed one. It didn&#39;t work. <code>--cacert</code> did nothing either.</p>
<p>macOS ships <code>curl</code> built against Apple&#39;s TLS backend, which <strong>doesn&#39;t accept a PEM cert/key pair</strong> and ignores <code>--cacert</code> entirely. You have to hand it a PKCS#12 bundle. Once I did that, it worked first try, and I felt a bit robbed of the hour I&#39;d just spent.</p>
<h3>Problem 8: the wrong port cost me an afternoon</h3>
<p>My control server is on a non-standard port. Port 443 on that <strong>same hostname</strong> is a completely different service (an NVR), with its own unrelated self-signed certificate.</p>
<p>So I probed 443, got a garbage certificate, and concluded my VPN server was misconfigured. It was fine. I was knocking on a different door in the same building.</p>
<h3>Problem 9: Gradle refused to build, for a reason it wasn&#39;t going to tell me</h3>
<p>My machine had a very new JDK. Gradle rejected it. Answer was just &quot;install JDK 17 and point <code>JAVA_HOME</code> at it,&quot; but the error message did not lead there in any obvious way.</p>
<h3>Problem 10: the app had NO network at all, and it looked like a server problem</h3>
<p>This is the single most misleading thing that happened in this whole project.</p>
<p>The app just couldn&#39;t reach anything. Not &quot;slow,&quot; not &quot;some requests fail.&quot; Zero network. I checked my server, my certs, my DNS, my code.</p>
<p>The actual cause: Android&#39;s battery optimisation. The app needs to be set to <strong>Unrestricted</strong>, and without it the system reports a blocked reason on every socket the app opens. It presents <em>identically</em> to your server being down.</p>
<p>If you&#39;re building anything network-heavy on Android, check this first, not last.</p>
<h3>Problem 11: a copy of my app I couldn&#39;t see</h3>
<p>Release builds started failing to install with a signature mismatch, even after uninstalling the app. I uninstalled it again. Same error. There was no app on the phone. The error insisted there was.</p>
<p>Turns out <code>adb install</code> installs for <strong>every user profile on the device</strong>, but <code>adb uninstall</code> only removes it from the main one. My phone has a Private Space, which is a separate user, and every dev install had been quietly seeding a copy in there. Android keeps one signature per package for the whole device, so that invisible debug-signed copy was blocking every release-signed build.</p>
<p>You have to unlock the Private Space and uninstall for that user specifically. There is no way you&#39;re guessing that from the error message.</p>
<h3>Problem 12: the logs kept disappearing</h3>
<p>Android&#39;s default log buffer is tiny, and the camera stack on my phone spams it constantly. My VPN logs were being evicted within seconds of appearing.</p>
<p>Bumping the buffer to 16MB fixed it. Should&#39;ve done it on day one.</p>
<h3>Problem 13: I couldn&#39;t reproduce my own bug</h3>
<p>I was launching the app from the command line to test connections, and nothing was happening. No dial attempts, no errors, no anything.</p>
<p>Because launching the app isn&#39;t connecting. The backend just sits in an idle state waiting for you to actually press the button. I had to physically tap it every time. Obvious in hindsight, extremely annoying at the time.</p>
<h2>Part 2: the plot twist</h2>
<p>So Headlink worked. I was happy. And then I ran into a wall that had nothing to do with my code.</p>
<p><strong>Android only lets one app hold the VPN slot at a time.</strong> I needed my tailnet <em>and</em> another networking client running at once, and that&#39;s just not a thing you can do. One or the other.</p>
<p>The only real fix was to stop having two apps. So I took the discovery feature I&#39;d built into Headlink and ported it into the other client, so one app does both jobs.</p>
<h3>Problem 14: same idea, completely different plumbing</h3>
<p>The second app has its own dialer abstraction, own address types, own config format. The logic transferred fine; everything around it had to be rewritten. Which is normal, but it&#39;s also where the next bug got in.</p>
<h3>Problem 15: THE BUG. And it was invisible at home.</h3>
<p>Ported everything, built it, tested at home. Worked great. Went out, switched to mobile data, and it couldn&#39;t find my server at all.</p>
<p>The logs said the connection timed out against the LAN address. Fine, that&#39;s expected, that&#39;s exactly the case my hook exists for. But the line where the hook says &quot;a dial failed, let me go look up the real address&quot;? <strong>Not there.</strong> Not an error. Just absent.</p>
<p>That absence was the entire clue. The hook was never being asked.</p>
<p>The cause: I had written the hook to skip anything that came in as a hostname instead of an IP, because normally the app resolves DNS first and hands the dialer an IP. But <strong>one specific request</strong> in the connection process, the very first key fetch, goes through a plain HTTP client, and those hand the dialer the raw hostname. So the one request that had to work was the exact one I was skipping.</p>
<p>And at home it never mattered, because at home the LAN address actually works.</p>
<p>Fix was to handle the hostname case: resolve it, then run the results through the hook like normal. Plus a regression test that literally has the phone&#39;s real log line in a comment, so future me knows this was a real thing that happened and not a hypothetical.</p>
<h3>Problem 16: my build lied to me</h3>
<p>After fixing that, I rebuilt. It took two seconds and said <code>BUILD SUCCESSFUL</code>.</p>
<p>Two seconds. For a change to native Go code.</p>
<p>The build was reusing a stale prebuilt native library and only recompiling the app around it. I had shipped the OLD code to my phone, with a green checkmark and everything. If I&#39;d trusted it, I&#39;d have spent the next hour debugging a fix that was never on the device.</p>
<p>I added a check to the build script: if any Go source is newer than the compiled library, rebuild the library first, no arguing.</p>
<p>I also stopped trusting build output entirely. Now I pull the APK back <strong>off the phone</strong> and search inside the compiled binary for the name of the function I just added. If it&#39;s not in there, it&#39;s not on the phone. That habit has caught things twice now.</p>
<h3>Problem 17: my patch files were basically empty</h3>
<p>I keep my changes as patch files so I can reapply them to newer upstream versions. I generated them, they looked fine, reasonable size.</p>
<p>They contained none of the feature.</p>
<p><code>git diff</code> <strong>ignores files git has never seen.</strong> All of my new files, which is most of the work, were silently skipped. The patch was just the handful of edits to existing files. You have to tell git the new files exist first (<code>git add -A -N</code>), and then the patch went from ~120 lines to ~800.</p>
<p>Terrifying, because that&#39;s the kind of thing you only discover months later when you try to restore from it.</p>
<h3>Problem 18: the moment I thought the whole project was wasted</h3>
<p>I tried importing a config share link into the app and got <strong>&quot;not a supported scheme.&quot;</strong></p>
<p>My stomach dropped. I genuinely thought I&#39;d spent weeks building on something that didn&#39;t support the thing I needed, and had to start over.</p>
<p>It wasn&#39;t that. That share-link <em>format</em> comes from a different project and this app has simply never read it. The underlying support was there the whole time, and my own logs from an earlier test literally showed it working. I just had to convert the link into the app&#39;s own config format instead of pasting it in. Wrote a small script to do the conversion and moved on.</p>
<p>Lesson I keep relearning: &quot;the app rejected my input&quot; and &quot;the app can&#39;t do this&quot; are very different sentences and panic does not distinguish them.</p>
<h3>Problem 19: startup depended on something that needed startup</h3>
<p>The app downloads some routing rule sets when it starts. Those downloads go through the tunnel. The tunnel isn&#39;t up yet. So on a bad network, startup would just <strong>fail</strong>, not degrade, fail.</p>
<p>Fixed by shipping the rule sets inside the APK and pointing the app at those as the initial copy, plus enabling its cache file so it remembers between runs. Now it starts from local data instantly and refreshes later if it can. Tested by deliberately breaking the connection: starts in 0.08 seconds.</p>
<h3>Problem 20: three config mistakes the config checker doesn&#39;t catch</h3>
<ul>
<li>Two options I was using got deprecated in a recent version and replaced by a different structure.</li>
<li>One of my sections pointed its traffic at an empty placeholder, which the app rejects at runtime with &quot;makes no sense&quot; (accurate).</li>
<li>Both of those pass <code>check</code> and only fail on <code>run</code>. So &quot;config is valid&quot; means less than you&#39;d hope.</li>
</ul>
<h3>Problem 21: editing JSON on a phone is miserable</h3>
<p>All my settings lived in a config file that I had to edit <strong>on my phone</strong> in a text box. Every tweak. It was awful.</p>
<p>So I built an actual settings screen. Control server, secret, certificate, all of it, stored encrypted in the keystore like before, with the ability to import a certificate bundle straight from the phone&#39;s file picker. It merges into the config on the way past, so the config file itself stays free of credentials.</p>
<p>It also validates: it refuses a control server given as a bare IP (there&#39;d be no hostname left to protect), refuses a non-HTTPS lookup URL, refuses an empty secret, refuses half a certificate pair. All the mistakes I&#39;d already made once.</p>
<h2>Part 3: the battery mystery</h2>
<p>Everything worked. Then I noticed my phone was looking up my server&#39;s hostname <strong>every two seconds</strong>, forever. That is not a thing a healthy app does, and it was going to eat my battery.</p>
<p>First instinct: something&#39;s wrong with DNS caching. Wrong. Almost all of those &quot;lookups&quot; were cache hits, one real query per DNS TTL. The lookups were a <strong>symptom</strong>, and I nearly spent a day fixing the wrong thing.</p>
<p>Here&#39;s the actual chain, and it&#39;s a good one:</p>
<ol>
<li>My relay server&#39;s certificate is self-signed, so away from home the app can&#39;t verify it.</li>
<li>Failure → retry with backoff.</li>
<li>Every retry re-runs a network conditions check.</li>
<li>That check&#39;s result flapped between two values.</li>
<li>A changed result counts as &quot;my network changed.&quot;</li>
<li>So the app posted a network update to the control server. TLS handshake plus an HTTPS POST.</li>
<li>Six to eight times a minute. Forever.</li>
</ol>
<p>Over 8 minutes I counted 199 lookups, 88 network checks, 38 update posts, 81 certificate errors and 88 backoffs.</p>
<p>The proper fix is a real publicly-trusted certificate on that relay, which I&#39;ll do. The immediate fix: the protocol supports <strong>pinning a specific certificate</strong> by its hash. That&#39;s a pin, not a bypass. Hostname verification still runs, expiry still runs, the only thing replaced is chain-to-CA verification.</p>
<p>I want to be really clear about that, because sitting a few lines away in the same source file is an option that genuinely does turn verification off, and it would also have &quot;fixed&quot; this. I didn&#39;t use it, and I wrote down why so I don&#39;t get tempted later.</p>
<p>After the pin, same measurement over an idle window: <strong>0, 0, 0, 0, 0.</strong> And the relay connected on the first try.</p>
<h3>The bonus: a workaround that had never once worked</h3>
<p>While in there I found that my server config had an &quot;ignore certificate errors&quot; option set. Except the key was spelled <strong><code>insecurefortest</code></strong> and the real one is <strong><code>InsecureForTests</code></strong>. Missing an <code>s</code>.</p>
<p>The config parser silently ignores keys it doesn&#39;t recognise. No warning, no error, nothing. So that line had been sitting there doing absolutely nothing for weeks, while I assumed it was handling the problem.</p>
<p>And I&#39;m leaving it broken, because fixing the typo would actually disable certificate verification, which is the thing I&#39;ve spent this entire project refusing to do. Deleted it instead.</p>
<h2>So how much of this was AI</h2>
<p>A lot. Honestly.</p>
<p>Where it was great:</p>
<ul>
<li><strong>Reading walls of logs.</strong> Thousands of lines of connection logs, and the answer being <em>which line is missing</em>. That&#39;s the kind of thing I&#39;d stare past twenty times.</li>
<li><strong>Knowing the shape of unfamiliar code.</strong> Names of functions in a networking library I&#39;d never touched, where the seams are, what&#39;s safe to hook.</li>
<li><strong>Talking me down.</strong> During the &quot;supported scheme&quot; panic, having something go &quot;wait, your own log from yesterday shows it working&quot; was worth a lot.</li>
</ul>
<p>Where I had to stay awake:</p>
<ul>
<li><strong>Confident wrong answers happen.</strong> The fake-200 telemetry thing would have quietly deleted my logs. The stale-build thing said BUILD SUCCESSFUL. Both looked correct.</li>
<li><strong>Verification beats agreement.</strong> Pulling the APK off the phone and grepping the binary is undramatic and never lies. Reading a success message is faster and lied to me directly.</li>
<li><strong>Security shortcuts need a hard &quot;no&quot; written down somewhere.</strong> There is always a one-line change that makes the error go away by disabling a check. I kept a rules file in the project saying never disable certificate verification, never log the discovered address, never store credentials in plain preferences. Having those written down meant every time we got near one, the answer was already decided instead of being re-argued at 1am when I just wanted it to work.</li>
</ul>
<p>That last one is my actual takeaway. Not &quot;AI good&quot; or &quot;AI bad.&quot; The tool is extremely fast at getting you to <em>something that runs</em>, and &quot;runs&quot; and &quot;correct&quot; are not the same word, especially when the whole point of your project is a privacy property that failing silently is the worst possible outcome for.</p>
<h2>The short version, if you skipped</h2>
<ul>
<li>Check Android battery settings before you debug your server</li>
<li><code>adb uninstall</code> doesn&#39;t remove other user profiles&#39; copies</li>
<li>The absence of a log line is data</li>
<li><code>git diff</code> doesn&#39;t see untracked files</li>
<li>A fast build is a suspicious build</li>
<li>macOS <code>curl</code> needs a P12, not a PEM pair</li>
<li>Config validators validate less than you think</li>
<li>A pin is not a bypass, but the bypass is always right there next to it</li>
</ul>
<p>Anyway. My phone finds my house from anywhere now, and my home address isn&#39;t in public DNS. Took a while.</p>
<h2>P.S.</h2>
<p>The repo is <a href="https://github.com/leodenglovescode/headlink-android">here</a> if anybody is interested :)</p>
]]></content:encoded>
  </item>
  <item>
    <title>Setting Up Headscale for Remote Access to My Home Server</title>
    <link>https://leodeng.dev/blog/tailscale-headscale-setup-blog</link>
    <guid>https://leodeng.dev/blog/tailscale-headscale-setup-blog</guid>
    <pubDate>Sun, 12 Jul 2026 10:36:24 GMT</pubDate>
    <description>What actually broke while getting my Mac, Windows PC, and phone to reach my home server remotely, and how I fixed each one.</description>
    <content:encoded><![CDATA[<p><strong>The problem:</strong> I wanted remote access to my homelab without turning half of it into public internet services. The goal was a private mesh between my server, Mac, Windows PC, and phone so they could reach each other more or less like they were on the same LAN, even when I was away.</p>
<p>The actual use cases were pretty normal homelab stuff: <strong>remote camera streaming</strong>, controlling <strong>Home Assistant</strong>, <strong>SSH</strong> into the server without exposing port 22, and editing/debugging code remotely without building a separate public endpoint for every service.</p>
<p>Tailscale is the obvious answer, but for this setup I wanted to self-host the control plane. That is where <strong>Headscale</strong> comes in: it implements the Tailscale coordination/control server, while the normal Tailscale clients still handle WireGuard, endpoint discovery, NAT traversal, and peer connectivity.</p>
<p>One distinction matters a lot here:</p>
<ul>
<li><strong>Headscale/Tailscale coordination is the control plane.</strong></li>
<li><strong>WireGuard peer traffic is the data plane.</strong></li>
<li><strong>DERP is a fallback relay when a direct peer path cannot be established.</strong></li>
</ul>
<p>The control server distributes keys, peer information, policy, and DERP maps. It normally does <strong>not</strong> sit in the middle of the traffic between two peers.</p>
<p>I also used AI tools heavily while building this: reading logs, comparing configs, checking assumptions, and helping turn the deployment notes into this writeup. The interesting part was not getting a perfect setup on the first try; it was seeing how all of the pieces behave once you stop testing from a single machine on a single LAN.</p>
<h2>Getting stable node identity</h2>
<p>The first version worked on my Mac, but after restarts I started seeing duplicate nodes appear in Headscale with new tailnet IPs and slightly different names.</p>
<p>For a private deployment I did not want routine node expiry, so I made that explicit:</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">node:</span>
  <span class="hljs-attr">expiry:</span> <span class="hljs-number">0</span></code></pre><p>After cleaning up the duplicate entries, the machine identity stayed stable.</p>
<p>One useful caveat: if a client registers as a genuinely new node after <strong>every</strong> reboot, do not only stare at the Headscale expiry setting. The client also needs to preserve its own local Tailscale state. A server-side expiry policy and lost client state can produce similar-looking symptoms from the admin side.</p>
<h2>TLS with a private CA</h2>
<p>I already had my own CA for internal services, so the natural next step was to sign a certificate for Headscale and trust that CA on the clients.</p>
<p>OpenSSL liked the chain. macOS did not.</p>
<p>The important detail turned out to be the leaf certificate itself. I had originally issued it with a <strong>10-year validity period</strong>, which is far longer than Apple&#39;s TLS requirements allow for server certificates issued on modern systems.</p>
<p>This is easy to confuse with Apple&#39;s newer <strong>398-day</strong> public-certificate limit. That newer rule applies to certificates chaining to roots in Apple&#39;s built-in trust store; it does not apply in the same way to a root CA that a user or administrator installed manually.</p>
<p>For my private-CA setup, the relevant Apple requirement was the older <strong>825-day maximum</strong> for TLS server certificates, along with the usual modern requirements such as SAN, serverAuth EKU, SHA-2 signatures, and adequate key sizes.</p>
<p>Reissuing the leaf certificate with a much shorter lifetime solved the problem.</p>
<p>So if a private PKI looks valid in OpenSSL but an Apple client still rejects it, I would check more than the chain:</p>
<ul>
<li>certificate lifetime</li>
<li>Subject Alternative Name</li>
<li>Extended Key Usage</li>
<li>signature algorithm</li>
<li>key size</li>
</ul>
<p>The error message is not always going to point at the exact one.</p>
<h2>IPv6-only server meets IPv4-only network</h2>
<p>My home server has a public <strong>IPv6</strong> address but no public IPv4 address.</p>
<p>That is fine until the client happens to be on an IPv4-only network.</p>
<p>At that point there may simply be no shared IP family for a direct UDP path between the two endpoints. Tailscale can normally punch through NAT and establish direct WireGuard paths, but it cannot make an IPv4-only network directly route to an IPv6-only endpoint without some translation mechanism in the middle.</p>
<p>This is where DERP matters.</p>
<p>I added a nearby self-hosted relay so that the fallback path stayed geographically close to the server:</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">derp:</span>
  <span class="hljs-attr">server:</span>
    <span class="hljs-attr">enabled:</span> <span class="hljs-literal">true</span>
    <span class="hljs-attr">region_id:</span> <span class="hljs-number">999</span>
  <span class="hljs-attr">paths:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-string">/etc/headscale/derp.yaml</span></code></pre><p>Headscale distributes the DERP map to the clients; the Tailscale clients measure the available regions and choose an appropriate relay when they need one.</p>
<p>The important part is that <strong>direct is still preferred</strong>. The relay exists for the cases where the network path genuinely cannot be direct.</p>
<h2>Keeping the control hostname pointed at a changing IPv6 address</h2>
<p>My ISP does not give the server a permanently static public IPv6 address, so I already had a small helper that kept an internal hostname mapped to the server&#39;s current address.</p>
<p>On macOS I used <code>/etc/hosts</code>:</p>
<pre><code class="hljs language-text">2409:xxxx:xxxx:xxxx::1 headscale.lan</code></pre><p>The script periodically queries a small authenticated endpoint, gets the current IPv6 address, and rewrites the entry only when it changes.</p>
<p>A simplified version looks like this:</p>
<pre><code class="hljs language-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -euo pipefail

ENDPOINT=<span class="hljs-string">&quot;YOUR_SYNC_ENDPOINT_HERE&quot;</span>
SECRET=<span class="hljs-string">&quot;YOUR_SECRET_HERE&quot;</span>
CA_CERT=<span class="hljs-string">&quot;YOUR_CERT_PATH/ca.crt&quot;</span>
CLIENT_CERT=<span class="hljs-string">&quot;YOUR_CERT_PATH/client.crt&quot;</span>
CLIENT_KEY=<span class="hljs-string">&quot;YOUR_CERT_PATH/client.key&quot;</span>
HOSTNAME_ENTRY=<span class="hljs-string">&quot;headscale.lan&quot;</span>

CURRENT_IPV6=$(grep -E <span class="hljs-string">&quot;[[:space:]]<span class="hljs-variable">${HOSTNAME_ENTRY}</span>([[:space:]]|$)&quot;</span> /etc/hosts \
  | awk <span class="hljs-string">&#x27;{print $1}&#x27;</span> | <span class="hljs-built_in">head</span> -1 || <span class="hljs-literal">true</span>)

RESPONSE=$(curl --silent --fail --max-time 15 \
  --cacert <span class="hljs-string">&quot;<span class="hljs-variable">$CA_CERT</span>&quot;</span> \
  --cert <span class="hljs-string">&quot;<span class="hljs-variable">$CLIENT_CERT</span>&quot;</span> \
  --key <span class="hljs-string">&quot;<span class="hljs-variable">$CLIENT_KEY</span>&quot;</span> \
  -H <span class="hljs-string">&quot;X-Sync-Secret: <span class="hljs-variable">$SECRET</span>&quot;</span> \
  <span class="hljs-string">&quot;<span class="hljs-variable">$ENDPOINT</span>&quot;</span>)

NEW_IPV6=$(<span class="hljs-built_in">printf</span> <span class="hljs-string">&#x27;%s&#x27;</span> <span class="hljs-string">&quot;<span class="hljs-variable">$RESPONSE</span>&quot;</span> \
  | python3 -c <span class="hljs-string">&quot;import sys,json; print(json.load(sys.stdin)[&#x27;ipv6&#x27;])&quot;</span>)

<span class="hljs-keyword">if</span> [[ -z <span class="hljs-string">&quot;<span class="hljs-variable">$NEW_IPV6</span>&quot;</span> || <span class="hljs-string">&quot;<span class="hljs-variable">$NEW_IPV6</span>&quot;</span> == <span class="hljs-string">&quot;<span class="hljs-variable">$CURRENT_IPV6</span>&quot;</span> ]]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">exit</span> 0
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">sudo</span> /bin/sh -c <span class="hljs-string">&quot;
  awk -v host=&#x27;<span class="hljs-variable">$HOSTNAME_ENTRY</span>&#x27; &#x27;\$2 != host { print }&#x27; /etc/hosts &gt; /tmp/hosts.tmp
  printf &#x27;%s %s\n&#x27; &#x27;<span class="hljs-variable">$NEW_IPV6</span>&#x27; &#x27;<span class="hljs-variable">$HOSTNAME_ENTRY</span>&#x27; &gt;&gt; /tmp/hosts.tmp
  cat /tmp/hosts.tmp &gt; /etc/hosts
  rm -f /tmp/hosts.tmp
&quot;</span></code></pre><p>I run it periodically through <code>launchd</code>.</p>
<p>A subtle but important point: this changes how the Mac reaches the <strong>Headscale control server</strong>. It does not force Tailscale peer traffic to use that same address. Peer-to-peer WireGuard paths are negotiated separately.</p>
<p>That distinction becomes important later.</p>
<h2>Windows and Android exposed the DNS bootstrap problem</h2>
<p>The Mac worked because it already had the hostname in <code>/etc/hosts</code>.</p>
<p>Windows and Android did not.</p>
<p>Both clients sat on &quot;connecting&quot; because the control-server hostname only existed locally on the Mac. Before a new client can join the tailnet, it still needs some normal way to resolve and reach the Headscale server.</p>
<p>Tailscale has bootstrap-DNS behavior that can help when ordinary DNS is broken, but that only helps for names that actually exist in resolvable DNS. It cannot discover a private hostname that was never published anywhere and only exists in another machine&#39;s hosts file.</p>
<p>The clean solution was to give Headscale a real subdomain and issue a certificate for that name using DNS-01.</p>
<p>Once the control hostname was something every device could resolve, Windows and Android connected normally.</p>
<p>That does <strong>not</strong> mean a Headscale hostname must always be public. A private hostname works perfectly well if every client already has DNS or hosts-file resolution for it. Mine simply did not.</p>
<h2>Control-plane DNS and data-plane performance are separate problems</h2>
<p>Around the same time as the hostname migration, I noticed some connections that had previously been direct showing up as relayed, with much worse throughput.</p>
<p>This is exactly the kind of thing that is easy to misdiagnose because two changes happen at the same time.</p>
<p>The Headscale hostname determines how a client reaches the <strong>control plane</strong>. It does not determine the address used for normal peer-to-peer WireGuard traffic.</p>
<p>The data path is negotiated separately:</p>
<ol>
<li>peers learn about each other through the control server;</li>
<li>the clients discover candidate endpoints;</li>
<li>Tailscale tries to establish a direct UDP path;</li>
<li>if that fails, traffic can fall back to DERP.</li>
</ol>
<p>So if a connection suddenly becomes relayed, changing the DNS record for the Headscale hostname is not, by itself, a real explanation.</p>
<p>The useful tools are things like:</p>
<pre><code class="hljs language-bash">tailscale status
tailscale ping &lt;peer&gt;
tailscale netcheck
tailscale debug derp-map</code></pre><p>Then look at the actual path: UDP reachability, NAT behavior, firewall rules, discovered peer endpoints, and the DERP region being selected.</p>
<p>I still use local hosts/split-DNS overrides where they are useful for making the <strong>control server</strong> reachable at the right address. I just treat that separately from whether the peer connection itself is direct.</p>
<p>My Windows desktop is a nice example: it sits on the same LAN as the server, so the peers can normally establish a local direct path regardless of what hostname I use to reach Headscale.</p>
<h2>Android is the least flexible client in this setup</h2>
<p>macOS is easy to customize because I can rewrite <code>/etc/hosts</code>.</p>
<p>A normal unrooted Android phone is not.</p>
<p>That means any design that depends on a per-device hosts override becomes awkward on Android very quickly. DNS override apps exist, but most are built around filtering or VPN-based DNS interception rather than acting as a transparent replacement for <code>/etc/hosts</code>.</p>
<p>The more robust design is therefore to make the Headscale control endpoint reachable in a way the phone can actually use remotely: public endpoint, reverse proxy, split-horizon DNS where appropriate, or some other path that does not depend on root access.</p>
<p>Existing Tailscale connections can survive temporary loss of the coordination server for a while because the clients cache peer state, but that is not a substitute for a reachable control plane. New peer information, policy changes, key operations, and re-registration still depend on it.</p>
<h2>Windows kept the old control server in local state</h2>
<p>The final annoying issue was Windows remembering the old login server after I had moved everything to the new hostname.</p>
<p>Changing command-line flags was not enough because the client had already persisted its state locally.</p>
<p>The clean first choice is still the normal route: logout, reset/reconfigure, and enroll the machine again.</p>
<p>In my case I eventually treated it as a fresh client: stopped the service, made sure the client processes were gone, removed the relevant local Tailscale state, and re-enrolled it against the new Headscale URL.</p>
<p>That is a <strong>destructive reset</strong>, not something I would recommend as the first troubleshooting step. It throws away local Tailscale state and effectively makes the machine a new client again.</p>
<h2>Where the setup ended up</h2>
<p>The final architecture is much easier to reason about once the control plane and data plane are kept separate.</p>
<h3>Control plane</h3>
<p>Headscale lives behind one real hostname with a valid TLS certificate.</p>
<p>Every device that needs to join, re-register, receive peer updates, or pick up policy changes needs a way to reach that hostname.</p>
<p>Depending on the device and network, that can be:</p>
<ul>
<li>normal public DNS + a reachable endpoint</li>
<li>split-horizon DNS</li>
<li>a reverse proxy/VPS</li>
<li>an explicit local override</li>
</ul>
<h3>Data plane</h3>
<p>The actual traffic between peers is handled by Tailscale/WireGuard.</p>
<p>The clients try to establish a direct UDP path first. If the two networks cannot talk directly, for example an IPv4-only client reaching an IPv6-only server with no translation path, DERP provides the fallback.</p>
<p>That separation cleared up most of the confusing behavior:</p>
<ul>
<li>TLS and hostname problems are <strong>control-plane</strong> problems.</li>
<li>Direct-vs-relayed performance is a <strong>data-plane</strong> problem.</li>
<li>The self-hosted DERP exists for the second one, not the first.</li>
</ul>
<p>The setup ended up being less about one clever config file and more about making each layer explicit: identity, TLS, DNS, control-plane reachability, peer discovery, and relay fallback.</p>
<p>Once those layers were separated, the system became a lot less mysterious.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Hello, World!</title>
    <link>https://leodeng.dev/blog/hello-world</link>
    <guid>https://leodeng.dev/blog/hello-world</guid>
    <pubDate>Mon, 25 May 2026 20:09:55 GMT</pubDate>
    <description>First post in the NEW blog!</description>
    <content:encoded><![CDATA[<p>Hello, welcome to the blog.</p>
<p>I&#39;ve been thinking about writing a blog for a long time but never really committed to it haha, but this will be a new place for me to occasionally write some interesting stuff.</p>
<h2>What to expect from my blogs</h2>
<p>Probably a mix of:</p>
<ul>
<li>Build logs / project updates (ShutterWingPhotos, llmgps, basically whatever I am working on)</li>
<li>Self-hosting stuff: things that took me WAYYYY too long to figure out</li>
<li>IoT and HomeAssistant experiments</li>
<li>Occasional hot takes on AI tools</li>
</ul>
<p>Nothing too formal. If something trips me up and took me a while to figure out, I&#39;ll write it down so future me (and maybe you) don&#39;t have to go through it again.</p>
<p>Anyways... first post is done. More content coming sometime soon :)</p>
]]></content:encoded>
  </item>
  <item>
    <title>Happy New Year!</title>
    <link>https://leodeng.dev/blog/happy-new-year-2025</link>
    <guid>https://leodeng.dev/blog/happy-new-year-2025</guid>
    <pubDate>Wed, 01 Jan 2025 04:49:38 GMT</pubDate>
    <description>Ringing in 2025 from Beijing.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog on New Year&#39;s Day 2025. It was the last thing I put on it before moving here.</p>
</div><p>It is January 1st, 2025 12:50 PM here in China (GMT +8 Beijing Standard Time).</p>
<p>and so… it is time to say…</p>
<h2>Happy New Year Everybody!</h2>
<p>I sincerely hope everybody has a great NEW &amp; AWESOME YEAR ahead!</p>
]]></content:encoded>
  </item>
  <item>
    <title>Updates... I guess...</title>
    <link>https://leodeng.dev/blog/updates-i-guess</link>
    <guid>https://leodeng.dev/blog/updates-i-guess</guid>
    <pubDate>Sun, 17 Nov 2024 04:47:00 GMT</pubDate>
    <description>End of 8th grade, a business competition in Suzhou, and a montage shot on the R6 across Suzhou and Tianjin.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in November 2024. The montage was a 4K HEVC file that most browsers refused to play. It is re-encoded here so it actually works.</p>
</div><p>Hi Again!</p>
<p>There has been a lot of things that happened in my life for the past few months, and explaning them one by one is kinda tough, so I&#39;m just going to round up what happened, first, 8th grade ended (and i definitely did not end up finishing my RaspberyPi handheld project lmao XD), then came summer vacation, I went to Suzhou for a business competition in which we had to &quot;make&quot; a game and market it, we got one medal but did not win a huge prize, I think our team ended up in 10th place but it doesn&#39;t really matter cause&#39; people say that &quot;The process is what matters.&quot; hey? Anyways, after the competition we stayed in Suzhou, exploring almost all of the gardens and museums there is, and I used my Camera (A Canon EOS R6) to take pictures around the famous tourist attractions, near the end of the summer vacation, we took a train back, which took a detour in Tianjin, so we also decided to stay there for a bit, and it was really fun :) Thats about it for the summer vacation updates and after that, the new semester starts.</p>
<p>Some Carefully Selected Pictures I took in a montage video:</p>
<video controls preload="metadata" playsinline style="max-width:min(100%,360px);height:auto;margin-inline:auto;display:block;border-radius:0.5rem">
  <source src="https://media.leodeng.dev/suzhou-tianjin-montage.mp4" type="video/mp4">
  Your browser can't play this video. <a href="https://media.leodeng.dev/suzhou-tianjin-montage.mp4">Download it instead.</a>
</video><p>(btw the title is in Mandarin because I&#39;m Chinese if u didnt know already, the title means &quot;If you took a camera to Suzhou &amp; Tianjin, you will get…&quot;)</p>
]]></content:encoded>
  </item>
  <item>
    <title>Raspberry Pi PDA</title>
    <link>https://leodeng.dev/blog/raspberry-pi-pda</link>
    <guid>https://leodeng.dev/blog/raspberry-pi-pda</guid>
    <pubDate>Fri, 05 Apr 2024 03:55:20 GMT</pubDate>
    <description>Building a handheld Raspberry Pi 4B with its own battery, speakers and 3D-printed case, for about ¥150 in parts.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in April 2024. This is a build log plus a separate &quot;Tutorial Part 1&quot; that never got a part 2, merged into one post here.</p>
</div><p>Hey!</p>
<p>As I have mentioned in the last blog, I was preparing to make a small Raspberry Pi PDA (or Handheld PC) using my spare RPI 4B, and after about 20 days or so, I am 90% done building it, so the objective was to build a compact device to code on and to take everywhere, so I listed some basic functions:</p>
<ol>
<li>Charge-free for at least 2 hours under full load (2 Amps)</li>
<li>Fits in my hands</li>
<li>Adequately cooled</li>
<li>No external Keyboard or mouse unless necessary</li>
<li>External USB 3.0 Ports</li>
<li>External FULL-SIZED HDMI port</li>
<li>System set up for tinkering and learning (I Promise :3)</li>
<li>Plays Sound</li>
</ol>
<h2>Components list</h2>
<p>With all those function requirements, I went to work and made a components list.</p>
<ol>
<li>Raspberry Pi 4B (x1)</li>
<li>32 GB SD Card (x1)</li>
<li>3.7v 3000mah Lithium Polymer Battery (Totals 6000mah) (x2)</li>
<li>Charge/Boost Converter 2 in 1 Module (x1)</li>
<li>Type-C Female Port (x2)</li>
<li>Micro HDMI Male to HDMI Female Connector (x1)</li>
<li>18 AWG Wires (x4)</li>
<li>Rectangular On-Off Switch (x1)</li>
<li>3.5 AUX Male to Positive/Negative Wire (x1)</li>
<li>8Ω 2W Wired Water-proof Speakers (x1)</li>
<li>Rii Mini X1 Keyboard (x1)</li>
<li>USB 3.0 2-port conjoined Female Port (x1)</li>
</ol>
<p>Here is some pictures of the components in case you can&#39;t find it:</p>
<p>USB 3.0 2-port conjoined Female Port:</p>
<p><img src="https://media.leodeng.dev/rpi-pda-usb3-connector.webp" alt="A conjoined two-port USB 3.0 female connector"></p>
<p>Rii Mini X1 Keyboard:</p>
<p><img src="https://media.leodeng.dev/rpi-pda-rii-mini-x1.webp" alt="The Rii Mini X1 wireless keyboard with trackpad"></p>
<p>8Ω 2W Wired Water-proof Speaker:</p>
<p><img src="https://media.leodeng.dev/rpi-pda-speakers.webp" alt="A small round 8-ohm waterproof speaker"></p>
<p>3.7v 3000mah Lithium Polymer Battery:</p>
<p><img src="https://media.leodeng.dev/rpi-pda-battery.webp" alt="A 3.7V 3000mAh lithium polymer pouch cell"></p>
<h2>The case</h2>
<p>Then, based on the size of the components, I modeled a case for it in Autodesk Fusion:</p>
<p><img src="https://media.leodeng.dev/rpi-pda-case-front.webp" alt="Fusion render of the PDA case, front view"></p>
<p><img src="https://media.leodeng.dev/rpi-pda-case-back.webp" alt="Fusion render of the PDA case, back view"></p>
<p>After creating the model, I bought the components, and the price was totaling about 150¥ (CNY) or about 21$ (USD), excluding the Raspberry Pi and the 32 Gigabyte MicroSD Card which I already have, then I pieced the components together and got it working without the case!</p>
]]></content:encoded>
  </item>
  <item>
    <title>Recent updates #3 and a new project</title>
    <link>https://leodeng.dev/blog/recent-updates-3-and-new-project</link>
    <guid>https://leodeng.dev/blog/recent-updates-3-and-new-project</guid>
    <pubDate>Wed, 27 Mar 2024 14:55:41 GMT</pubDate>
    <description>2nd place at the NSL RC Racing competition, hotpot in Chongqing, and the start of the Raspberry Pi handheld.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in March 2024. The &quot;Geeker tablet&quot; it ends on is the <a href="/blog/raspberry-pi-pda">Raspberry Pi PDA</a> I wrote up a week later.</p>
</div><p>Hey guys!</p>
<p>Long time no see after the Chinese New Year vacation, I along with my teammates, came home with a 2nd place in the NSL RC Racing competition, there was a few moments that left our hearts pounding, but eventually we got a 2nd place, which is very impressive to me seeing how we all collaborated together. I went to a few different places before and after the race, of which cities are all in China of course (Chongqing, Fuzhou, Xiamen (Competition place)) it all came out to be pretty fun, In Chongqing, I Had a delicious meal of authentic Chongqing hotpot (Not very spicy for a person from Sichuan like me lol, from my POV I would give it a 6.5/10 spicy rating but if you are a foreigner, watch out! it&#39;s bound to be the spicest thing you&#39;ve ever had), In Xiamen, I went to several beaches (BaiCheng Beach, HuangCuo Beach and much more views, all of which were amazing! They had a incomparable view of the sea, which I definitely couldn&#39;t see in Beijing). In Fuzhou, I went to the infamous San Fang Qi Xiang (three squares and seven lanes) and visited various artists, poets, and author&#39;s former residences. So anyways, this trip was a total blast for me! :33333</p>
<p>Ok and now with the updates… I have not worked on anything in the vacation, but after the vacation, which is now (oh and sry for writing a post this late) I joined a Maker&#39;s Club and decided to build a Geeker tablet powered by the Raspberry Pi 4B that has multiple Functions, I used &quot;The world&#39;s smallest keyboard&quot; as the keyboard and trackpad (yes you did not look at the wrong sentence, it does indeed have a small trackpad), 3000+3000=6000mah battery for long tinkering sessions (lol jk it&#39;s mainly just for the battery life to be longer because I want it to do more things instead of just die in 1hr without charging), ports for extending, and at last a proper Linux install for E F F I C I E N C Y and Security! I am working on this project as of now and prepare to finish it before May 2024, so check out my new updates!</p>
<p>That&#39;s about it for this update and I&#39;ll see you (404 undefined)!</p>
]]></content:encoded>
  </item>
  <item>
    <title>An update on recent things that happened</title>
    <link>https://leodeng.dev/blog/an-update-on-recent-things-that-happened</link>
    <guid>https://leodeng.dev/blog/an-update-on-recent-things-that-happened</guid>
    <pubDate>Sun, 07 Jan 2024 13:51:56 GMT</pubDate>
    <description>Back after eight months: 8th grade, an RC car competition in Xiamen, and two new projects: YAUMSI and SafeSphereX.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in January 2024.</p>
</div><p>We meet again!</p>
<p>It has been 8 months and if you are being stricter, a whole new year since I have posted a new blog, I almost completely forgot about it until now in 2024, schoolwork and things are fine, Maths are hard as always, and I don&#39;t play games that much (btw cs2 fps and ping sucks on my gtx960 and gunk-filled pc), semester 1 in 8th grade has not ended for me or others just yet, we&#39;ll just have to wait another 3 weeks and then it will be Chinese New Year of 2024 (Year of the Dragon according to the Chinese Zodiac). At this time of the year we get to relax, travel to places and mostly, enjoy our time with our own families. I have a STEM RC Car Competition coming up on Feb 6th in Xiamen, and my birthday lands right after the break!</p>
<p>For programming projects (Check out all of them on my <a href="https://github.com/leodenglovescode">Github</a>), I have been working on two new things, one of them is YAUMSI (Yet Another Universal Minecraft Server Installer) because I wanted people to create new minecraft servers at home more easily instead of finding online hosting services which are pretty expensive (if you are rich please skip this entire part, Thanks! :3), and SafeSphereX, which is basically an Antivirus for mac (does not really detect viruses tbh).</p>
<p>I guess that&#39;s all for today (or perhaps this week).</p>
<p>See you around, Friend!</p>
]]></content:encoded>
  </item>
  <item>
    <title>ezC!te version 2.0 is out. Sorry for being so late...</title>
    <link>https://leodeng.dev/blog/ezcite-2-0-is-out</link>
    <guid>https://leodeng.dev/blog/ezcite-2-0-is-out</guid>
    <pubDate>Sun, 21 May 2023 04:31:37 GMT</pubDate>
    <description>ezC!te 2.0 shipped back in March and I forgot to mention it. Here are the download links.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in May 2023.</p>
</div><p>Hello Readers!</p>
<p>Smart People (You Probably) have realized that I have released ezcite 2.0! (*but its like released on march... so sorry for being so late), or realized that the ezcite isn&#39;t in my repos! That&#39;s because I moved it to my organization! (The Interstellar Progeammers). Anyways, if you see that, then congrats! you are officially a huge fan of ezcite!</p>
<p>The <a href="https://github.com/The-Interstellar-Programmers/ezcite/releases/download/v2.0.0/ezcite-v2.0.0.dmg">Download Link (DMG FILE! ONLY FOR MAC RN)</a> is here in case you can&#39;t find it</p>
<p><a href="https://github.com/The-Interstellar-Programmers/ezcite/releases/tag/v2.0.0">Download Link For Font File &quot;Ubuntu&quot; (Please download for best experience!)</a></p>
<p><a href="https://github.com/The-Interstellar-Programmers/ezcite/releases/download/v2.0.0/Starjedi.ttf">Download Link For Font File &quot;StarJedi&quot; (Optional)</a></p>
<p>P.S. The reason ezCite has no new versions is because me, as a &#39;professional&#39; programmer, forgot to sync all of my files (Wow Leo why u so dumb... eh?), and my school computer broke down... so yeah, my data is fine but the technicians are fixing it.</p>
]]></content:encoded>
  </item>
  <item>
    <title>My Counter-Strike: Source server is now open</title>
    <link>https://leodeng.dev/blog/my-counter-strike-source-server</link>
    <guid>https://leodeng.dev/blog/my-counter-strike-source-server</guid>
    <pubDate>Tue, 04 Apr 2023 07:42:02 GMT</pubDate>
    <description>Running a full deathmatch CS:S server off an old ThinkPad, plus the SourceMod plugin list that made it work.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog as two posts a day apart in April 2023, merged into one here. The server is long gone, so I&#39;ve dropped the live status banner and the IP address.</p>
</div><p>Long time no see!</p>
<p>I&#39;ve been having a lot a trouble on schoolwork and time recently, so no time for blogs, during the Qing Ming Festival Break in China <strong>(Similiar to Mexico&#39;s Day of the Dead but not quite)</strong>, I finally can have a little break where I can make projects I like!</p>
<p>As I said before, I am obsessed with old games and old tech, which led me to think about old Online Multiplayer Games such as GMOD, Counter Strike 1.6, Source and more, so I thought: Why not make a old Game server to bring that sense of nostalgia back? Well I did it, looking for tutorials and asking on the AlliedModders Forum for a lot of time, and setting up all kinds of plugins. Now what I Have is a Full On DeathMatch Server running on my old Thinkpad with Upgraded 8 GB RAM and 128GB SSD, which working amazingly good in 2023!</p>
<p>I Will Post The <em><strong>IP Address of my server</strong></em> Later in the Month, and I wish you all a good holiday!</p>
<p>:3</p>
<h2>The next day: the link, and more</h2>
<p>So as I promised, here&#39;s the banner of my server, including the ip address of my CSS Server: it was a GameTracker badge pointing at my home connection on port 27015.</p>
<p>Oh and also my server has SourceMod, MetaMod, and some plugins Installed on it, I have a list of the plugins at the bottom of this page if you guys want to see for yourself, it is really just a few files and the SourceMod Plugins command just shows all of the files associated with each plugin.</p>
<p>Anyways the way I got the server running over my network was pretty frustrating, I will come up with a tutorial on How to build a Source Dedicated Server and how to setup a NAT Reverse Proxy. I would also consider opening more Source/HLDS Servers from different games and test it out myself! I&#39;ve filtered the possible candidates for the server creation which are: Lead 4 Dead 2, HL2 DM, TF2, and maybe GMOD.</p>
<p>Well I think that&#39;s that for this hour, c u 4 hours later with a NAT Tutorial! Bye! :3</p>
<h3>The list of plugins on my CSS server</h3>
<ul>
<li>&quot;Admin Help&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Commands&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Player Commands&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Show Damage&quot; (1.0.7) by exvel</li>
<li>&quot;Fun Commands&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;MapChooser&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Sound Commands&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Info Triggers&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Votes&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Chat&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Anti-Flood&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Ban Commands&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;CS:S DM Bot Quotas&quot; (2.1.6-dev) by AlliedModders LLC</li>
<li>&quot;Client Preferences&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;RankMe&quot; (2.8.3) by lok1</li>
<li>&quot;Nextmap&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Fun Votes&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Basic Comm Control&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Admin Menu&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Rock The Vote&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Advertisements&quot; (2.1.1) by Tsunami</li>
<li>&quot;CS:S DM Spawn Protection&quot; (2.1.6-dev) by AlliedModders LLC</li>
<li>&quot;CS:S DM Basics&quot; (2.1.6-dev) by AlliedModders LLC</li>
<li>&quot;CS:S DM Equipment&quot; (2.1.6-dev) by AlliedModders LLC</li>
<li>&quot;CS:S DM Preset Spawns&quot; (2.1.6-dev) by AlliedModders LLC</li>
<li>&quot;Admin File Reader&quot; (1.11.0.6934) by AlliedModders LLC</li>
<li>&quot;Reserved Slots&quot; (1.11.0.6934) by AlliedModders LLC</li>
</ul>
]]></content:encoded>
  </item>
  <item>
    <title>Updates and recent projects Ver. 2</title>
    <link>https://leodeng.dev/blog/updates-and-recent-projects-ver-2</link>
    <guid>https://leodeng.dev/blog/updates-and-recent-projects-ver-2</guid>
    <pubDate>Wed, 16 Nov 2022 02:37:58 GMT</pubDate>
    <description>Three weeks of summatives, and the start of ezCite.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in November 2022.</p>
</div><p>Hello! It has been a stressful three weeks so I didn&#39;t post anything, summatives, formatives are now crawling up my leg and pulling me into a endless hole.</p>
<p>So, updates… I have recently been working on a free-and-no-ad citation generator called ezCite, and I already have the terminal version of that, so good for me…. More Updates later!</p>
]]></content:encoded>
  </item>
  <item>
    <title>What I'm working on recently</title>
    <link>https://leodeng.dev/blog/what-im-working-on-recently</link>
    <guid>https://leodeng.dev/blog/what-im-working-on-recently</guid>
    <pubDate>Sat, 02 Jul 2022 13:09:46 GMT</pubDate>
    <description>HackGen, 6th grade online classes, CS:GO screenshots on a GTX 960, and a sci-fi novel I was going to write.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in July 2022, lightly trimmed since. It points at a &quot;Downloads&quot; page that only existed there, so that reference has nowhere to go now.</p>
</div><p>So, Recently I was spending a lot of time doing boring online classes of 6th grade <em>(Which sucks, IRL SCHOOLS ARE THE BEST)</em>, and also my programming project &quot;HackGen: A hacking/programming simulator&quot; which is currently now available on MacOS and Linux! (Most distros) … find it on the &quot;Downloads&quot; Page of my blog to download it if you want, or just click on this repo card and go to the &quot;releases&quot; section of the repo page.</p>
<p>Oh right, check out and follow my <a href="https://github.com/leodenglovescode">Github profile</a> too!</p>
<p>For fun I played like 40 mins of CS:GO every week on my &quot;low-end pc&quot;, CSGO was running smoothly at 120-140 fps at lowest settings (but models at the highest settings) with a NVIDIA GTX 960 GPU. Here are some funny screenshots:</p>
<p>Hey! Whatcha lookin&#39; at!</p>
<p><img src="https://media.leodeng.dev/csgo-t-funny.webp" alt="A CS:GO terrorist model in a funny pose"></p>
<p>Hi i&#39;m dead</p>
<p><img src="https://media.leodeng.dev/csgo-ct-funny.webp" alt="A CS:GO counter-terrorist ragdoll"></p>
<p>T (*pats on CT) : Buddy, how&#39;s this match? CT (*pats back): oof, pretty good and goofy, I would say</p>
<p><img src="https://media.leodeng.dev/csgo-t-and-ct-funny.webp" alt="A CS:GO terrorist and counter-terrorist standing together"></p>
<p>Anyways even though I was born in 2010 (12 years old now), Old games still bring me nostalgia, like Counter Strike 1.6, Source, Half Life (Wasn&#39;t born when these games are made).</p>
<p>Later I will add some webpage games that you guys can play in your web browsers!</p>
<p>One last thing, I&#39;ve been wanting to start a novel for months, I finally got a chance about this summer, the novel&#39;s name is &quot;Escape from planet Earth&quot;, it&#39;s kind of an sci-fi novel, I got partial inspiration of another Mars related novel &quot;The Martian&quot; by Andy Weir, It&#39;s a great novel! Go read it!</p>
<p>Well I think that&#39;s all for this update! Bye! :3</p>
<p><em><strong>JULY 5TH UPDATE</strong></em>:</p>
<p>Disqus was added to the site! Also I am going to write in diary every week or so.</p>
]]></content:encoded>
  </item>
  <item>
    <title>How to make a Raspberry Pi NAS server</title>
    <link>https://leodeng.dev/blog/how-to-make-a-raspberry-pi-nas-server</link>
    <guid>https://leodeng.dev/blog/how-to-make-a-raspberry-pi-nas-server</guid>
    <pubDate>Sat, 09 Apr 2022 11:20:00 GMT</pubDate>
    <description>Turning a Raspberry Pi 4B and a leftover drive into a Samba file server you can reach from every machine in the house.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in April 2022. Kept as written. The prices, the links and the advice are all from 2022.</p>
</div><p>Imagine this senario, you need a lot of pictures and/or important files stored someplace locally, and you want to access it through every computer/phone, but all of your storage is full, how do you store those pictures/files?</p>
<p>Well, the Raspberry pi can be a great soulution. The Raspberry Pi uses a lot less power than a regular NAS server, and it&#39;s pretty cheap and easy to setup. So here&#39;s how to make it...</p>
<h2>Step 1: Getting the components</h2>
<p>First, you need these components, some are needed and some are optional:</p>
<ol>
<li><strong>Raspberry Pi 4B / 2G RAM</strong>: $35 Dollars ¥223 Yuan (You can buy the 4G and 8G version, but it&#39;s more pricey)</li>
<li><strong>Case for Raspberry Pi</strong> (Optional): If you are a geek, you can 3D print one like I did, anyways, <a href="https://media.leodeng.dev/raspberry-pi-nas-case-files.zip">here it is</a>. (Credit to Michael Klements for designing the case (Aaaand making his own tutorial of the making of the case), he is a great tech youtuber and you should definitely check him out!)</li>
<li><strong>Micro SD Card</strong> (Sandisk 32 GB here): 14 Dollars/89 RMB. This is for system installation, or if you only have a few files, using a 32 gigabyte sd card would be fine.</li>
<li><strong>SSD or HDD</strong> (Optional) (Can be Leftover SSD or HDDs)</li>
<li><strong>I2C display</strong> (Optional if you want the case)</li>
<li><strong>3.3V Fan</strong> (Optional if you want the case)</li>
</ol>
<h2>Step 2: Installing the Raspberry Pi OS</h2>
<ol>
<li>Download the Raspberry Pi OS from <a href="https://www.raspberrypi.org/software/operating-systems/">here</a>. If you want a GUI Graphical Desktop to do gui applications, you can download the full version, but if you want it to be a lighter system and run more smoothly as a NAS server, use the lite version of the OS.</li>
<li>Open Win32DiskImager and choose the image and transfer it to the Micro SD card <strong>OR</strong> use Etcher on mac and Rufus on windows, same steps.</li>
<li>Plug the Micro SD card into the raspberry pi and start it.</li>
</ol>
<h2>Step 3: Commands for the SMB server</h2>
<ol>
<li><p>Open a terminal and enter this command, this command installs the samba module which is used to create the SMB server:</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> apt-get install samba</code></pre></li>
<li><p>After the necessary installation, use nano or vim to edit this file:</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> nano /etc/samba/smb.conf
<span class="hljs-comment"># or</span>
<span class="hljs-built_in">sudo</span> vim /etc/samba/smb.conf</code></pre></li>
<li><p>Write these lines of code at the end of the file. Inside the top big braclet <code>[]</code>, you should write your NAS Share name there without spaces, e.g. <code>admins-nas-share</code>, <code>Nas-share</code>. The path should be the folder you want to share (Must be created) e.g. <code>/home/pi/NAS-SHARE</code>.</p>
<pre><code class="hljs language-ini"><span class="hljs-section">[Your NAS Share Name (No Spaces)]</span>
<span class="hljs-attr">path</span> = /Your/path/to/the/folder
<span class="hljs-attr">writeable</span>=<span class="hljs-literal">Yes</span>
create <span class="hljs-attr">mask</span>=<span class="hljs-number">0777</span>
directory <span class="hljs-attr">mask</span>=<span class="hljs-number">0777</span>
<span class="hljs-attr">public</span>=<span class="hljs-literal">no</span></code></pre></li>
<li><p>Create an account on samba with your Raspberry Pi username. After the terminal prompts you for an password, enter your desired password for samba.</p>
<pre><code class="hljs language-bash">smbpasswd -a (your raspberry pi user account)</code></pre></li>
<li><p>Restart the samba service:</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> systemctl restart smb</code></pre></li>
<li><p>Connect to your file-sharing service. On macOS, go to the &quot;Finder Menu bar&quot;:</p>
<p><img src="https://media.leodeng.dev/rpi-nas-finder-menu-bar.webp" alt="The macOS Finder menu bar"></p>
<p>Click &quot;Go&quot; and click &quot;Connect to server&quot;:</p>
<p><img src="https://media.leodeng.dev/rpi-nas-connect-to-server.webp" alt="The Go menu with Connect to Server highlighted"></p>
<p>After that, enter your NAS server&#39;s ip address and your NAS share name and click &quot;Connect&quot;:</p>
<p><img src="https://media.leodeng.dev/rpi-nas-connect-samba-ip.webp" alt="The Connect to Server dialog with the smb address filled in"></p>
<p>Click on your server and enter the <strong>samba</strong> credentials.</p>
</li>
</ol>
<p>Now you are done! Access all of your saved files, images, videos on the server!</p>
<p>Here&#39;s a showcase of the server:</p>
<p><img src="https://media.leodeng.dev/rpi-nas-showcase-1.webp" alt="The finished NAS in its 3D-printed case"></p>
<p><img src="https://media.leodeng.dev/rpi-nas-showcase-2.webp" alt="The NAS from another angle, showing the drive bays"></p>
<p><img src="https://media.leodeng.dev/rpi-nas-showcase-3.webp" alt="The NAS running, with the I2C display lit up"></p>
<p>Now doesn&#39;t that look good? :3</p>
<p>Bye!</p>
]]></content:encoded>
  </item>
  <item>
    <title>Hello, World! (2022)</title>
    <link>https://leodeng.dev/blog/hello-world-2022</link>
    <guid>https://leodeng.dev/blog/hello-world-2022</guid>
    <pubDate>Thu, 07 Apr 2022 12:34:00 GMT</pubDate>
    <description>The very first post on my very first blog, running on a Raspberry Pi.</description>
    <content:encoded><![CDATA[<div class="callout callout-note"><p class="callout-title">Note</p><p>Originally posted on my old blog in April 2022. It was the first thing I ever put on it.</p>
</div><p>Hello, World!</p>
<p>This is my first blog running on my Raspberry Pi server, using the hexo blog framework and linked with github!</p>
<p>See my next blog for more.</p>
]]></content:encoded>
  </item>
  </channel>
</rss>
