Tag: self-hosted

  • Ultimate Guide to Self-Hosted Dynamic DNS: Ditch DuckDNS for KarlDNS

    Ultimate Guide to Self-Hosted Dynamic DNS: Ditch DuckDNS for KarlDNS

    We’ve all been there. It’s 2:00 AM, you’re miles away from home trying to SSH into your homelab or access your Nextcloud instance, and you realize your ISP rotated your public IP address. No big deal, right? You check your free Dynamic DNS provider, only to find a devastating email buried in your spam folder:

    “Your hostname has been deleted because you didn’t log into our ad-ridden dashboard and click a manual verification link inside a full moon while standing on one leg.

    Dynamic DNS shouldn’t feel like a part-time job.

    Between aggressive premium upsells from commercial providers and the outright instability of some free alternatives, the self-hosting community has been left in a weird spot. If you want a “set-and-forget” setup under your own control, it’s time to meet KarlDNS. It’s a lightweight, self-hosted DynDNS service built for routers, homelabs, and infrastructure operators who want absolute control over their network edge without the typical platform headache.

    (Or give me, Karl, some control 😘)

    The Problem: Why Traditional DynDNS is an Architectural Trap

    Most Dynamic DNS setups suffer from a critical flaw: they either force you into a commercialized third-party silo, or they require you to build an overly complex, over-engineered DNS infrastructure that takes more time to maintain than the apps it serves.

    Ok yes, I may over exaggerate a little.. I am just jealous that Cloudflare-DDNS has so many more stars than me.

    When designing KarlDNS, we wanted to eliminate three major pain points:

    • The Over-Privileged API Token Problem: Most lightweight DynDNS scripts require you to hardcode an all-powerful Cloudflare or Route 53 global API key directly onto your home router or a random cron job. If that router gets compromised, an attacker inherits full read/write access to your entire domain registry.
    • Account Fatigue: Users shouldn’t have to create a profile, verify an email, and manage passwords just to map a shifting WAN IP to a domain name.
    • Bloated Infrastructure Dependencies: You do not need a multi-node Kubernetes cluster, a Redis caching tier, or a heavy PostgreSQL database engine just to translate a string into an IP address. That is structural overkill.

    Enter KarlDNS: The Elegant CNAME Architecture

    KarlDNS doesn’t try to be an all-encompassing DNS authoritative plane. It doesn’t handle your MX records, your email signatures, or your complex routing policies. Instead, it does one job flawlessly: it accepts an IP update from a client, validates it, and pushes that single update to a zone it explicitly controls.

    My goal was to get you a DDNS Setup in 2 clicks.

    To protect your root domains, KarlDNS relies on a clean CNAME delegation model:

    [Your Custom Domain] -> home.yourdomain.com
           
            (Standard CNAME Record)
    [KarlDNS Target]     -> a1b2c3d4.karldns.de
           
            (Dynamic A/AAAA Record via KarlDNS)
    [Your Home Router]   -> 203.0.113.10 (Your changing WAN IP)
    

    💡 The Trust Boundary Benefit: KarlDNS only directly manages the generated *.karldns.de subdomain target. You keep your root domain safe at your primary registrar. If KarlDNS goes offline or your self-hosted server reboots, your broader domain security configuration remains completely isolated and untouched.

    The Lifecycle of an Update

    When a client wants to spin up a new endpoint, the workflow is entirely decentralized:

    1. Token Generation: The user hits the KarlDNS UI (or API), which instantly provisions a completely randomized hostname slug (e.g., a1b2c3d4.karldns.de).
    2. Link Provisioning: The app hands back a trio of cryptographically secure bearer links: an update URL, a private management dashboard, and a registration link. No user accounts required.
    3. Router Handshake: The update URL is pasted into the router. Every time the WAN interface cycles, the router hits KarlDNS.
    4. Validation & Push: KarlDNS verifies the payload, updates its internal cache, and triggers a lightweight API push to the upstream provider (like Cloudflare or an RFC 2136 BIND server).

    For FRITZ!Box and open-source routing platforms (OpenWrt, pfSense, OPNsense), KarlDNS structures a native, drop-in update query:

    https://karldns.de/api/v1/router/update/<secret>?myip=<ipaddr>&myipv6=<ip6addr>

    The router dynamically swaps out and on the fly. KarlDNS parses the query strings and responds using lean, standard DynDNS-style string literals:

    HTTP Status / ResponseTechnical MeaningBackend Action
    good 203.0.113.10Successful UpdateUpstream DNS updated; local cache committed.
    nochg 203.0.113.10Redundant RequestIP hasn’t changed; execution skipped to save API quota.
    badipValidation FailureString failed regex check; entry immediately dropped.
    911 dns publish failedUpstream OutageCloudflare/BIND failed; local state preserved as “dirty”.

    Under the Hood: Choosing Practical Tech Over Hype

    The technology stack behind KarlDNS is intentionally boring. In an era of over-engineered microservices, KarlDNS leans into rock-solid, single-binary efficiency.

    Python FastAPI (The Asynchronous API Layer)

    Python’s FastAPI acts as the structural backbone. It handles the web UI rendering, serves the fast public /api/v1 namespace, and natively outputs a public OpenAPI JSON spec. Because it’s fully asynchronous, it can handle thousands of incoming router check-ins simultaneously without blocking system threads.

    SQLite in Production (WAL Mode for the Win)

    Yes, we use SQLite in production. For a workload that is 95% reads (checking routes, verifying tokens) and 5% writes (updating an IP once a day), spinning up a separate database container is a waste of RAM.

    By enabling Write-Ahead Logging (WAL), KarlDNS achieves concurrent reads and writes. Readers don’t block writers, and writers don’t block readers. The database is a single file (ddns.sqlite3), making system backups as simple as a standard cp or rsync command.

    Dual-Engine DNS Backends

    KarlDNS features a pluggable architecture that speaks two primary deployment languages:

    • Cloudflare API: Best for public cloud setups where Cloudflare manages the edge.
    • RFC 2136 standard: Best for true air-gapped homelabs or corporate intranets running local BIND, Knot, or PowerDNS daemons.

    The Production Architecture Blueprint

    We don’t just write code; we run it. Our recommended production layout isolates the system components inside a lightweight virtualization stack to minimize overhead while keeping security tight.

    The Actionable Deployment Playbook

    For those running an Alpine Linux LXC environment on Proxmox, here is how you manage the service infrastructure cleanly using native OpenRC and Docker Compose tools.

    Your standard operational stack lives under /opt/karldns. To spin up or update the setup, your terminal workflow is incredibly brief:

    # Drop into your deployment environment
    cd /opt/karldns
    
    # Spin up the container stack in the background
    docker compose up -d --build
    
    # Verify container running state
    docker compose ps
    
    # Tail live application logs for debugging
    docker compose logs -f karldns
    

    For more Info on .env variables consult the README on Github.

    If you are maintaining the system at the operating system layer via Alpine’s init system, you can control the Docker engine lifecycle directly without relying on heavy systemd components:

    # Check runtime engine health
    rc-service docker status
    
    # Set the daemon to automatically start on hypervisor boot
    rc-update add docker default
    

    Because KarlDNS implements strict host header checking to prevent HTTP Host header injection attacks, internal health validation from inside your local area network requires a targeted header payload:

    curl -H 'Host: karldns.de' http://127.0.0.1:8080/healthz

    Once its up you get a really nice Admin UI

    Security Engineering That Isn’t an Afterthought

    We hate passwords. They get leaked, they get reused, and managing them forces you to build password reset flows, registration loops, and MFA handling. KarlDNS bypasses this attack surface entirely by relying strictly on cryptographically secure bearer tokens prefixed with karl_.

    (That was a fancy way of saying: Passwords are “public” in the URL 😂)

    Possession of the specific bearer token is authorization. To make sure these tokens remain locked down, the application enforces deep defensive logic:

    • Lookup Hashes vs. Plaintext Slugs: The application never stores your dashboard URLs or tokens in plain text inside the database. They are converted into secure lookup hashes. If an attacker manages to download your ddns.sqlite3 file, they still cannot derive your private links.
    • Timing Attack Mitigation: Admin endpoints use HTTP Basic Auth powered by constant-time string comparisons. This prevents malicious actors from guessing admin credentials based on subtle CPU clock differentials during string checking.
    • Total Log Redaction: By setting DDNS_REDACT_ACCESS_LOGS=1, the web server strips all bearer keys out of your standard stdout logs, ensuring sensitive keys never leak into downstream log collectors like Grafana Loki or Filebeat.
    • Fortified Security Headers: Out of the box, the system injects a comprehensive security header suite, explicitly serving:
      • Content-Security-Policy (CSP) to prevent cross-site scripting.
      • X-Robots-Tag: noindex, nofollow to prevent search engine spiders from scraping your endpoints.
      • Cache-Control: no-store to prevent shared browsers from caching private token keys in history.

    No Smart Router? No Problem. Enter the Cron Updater

    If you are running a headless Linux server, a dedicated backup node, or a network-attached storage (NAS) appliance that lacks a custom DynDNS configuration UI, you aren’t left out. KarlDNS ships with a highly optimized shell updater utility.

    To provision a brand new hostname and configure an automated system cron loop directly from your command line interface, run:

    curl -fsSL https://karldns.de/install.sh | sudo sh

    For environments where you have already generated an orchestration token via the main web panel, you can anchor the installer directly to that pre-existing target endpoint:

    curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
      --update-url 'https://karldns.de/api/v1/router/update/karl_secret_token' \
      --hostname 'home.karldns.de'

    The client-side script is incredibly smart: it establishes a local lockfile to guarantee that multiple scheduled instances never collide, stores the last successfully broadcasted IP address locally, and will gracefully short-circuit its own execution unless a physical change in your WAN IP is detected.

    What KarlDNS Is Not

    Architectural discipline means knowing what not to build. KarlDNS is not a competitor to enterprise tools like OctoDNS, or ExternalDNS. It does not want to manage your broader corporate infrastructure DNS layers.

    It does one thing: it provides a bulletproof, self-managed path for dynamic IP synchronization. It keeps its scope locked so its execution footprint stays exceptionally small and its security boundary stays completely clear.

    If you are running a homelab, a small business network, a community infrastructure project, or just a friendly platform for your peers, KarlDNS gives you all the power of premium dynamic DNS without turning your infrastructure into someone else’s monetization strategy.

    In short: this is not a DDNS Service, it is the DDNS Service provider. Free, open source, self hostable.

    Demo

    You can actually use my service right now at KarlDNS.de

    You click on create and see this sort of dash, a subdomain is generated and pre-registered for you. The red values are secrets.

    You just copy the “Update-URL” and add it to your Fritzbox DynDNS tab, you can just add any username and password, they don’t matter but Fritzbox insists.

    In your secret dashboard you can see your current IPv4 and IPv6 Address and your update URL. You canals password protect this page as well and remove the subdomain if you want.

    I am using Cloudflare as my backend so this is what it looks like for karldns in Cloudflare.

    Summary

    To be hontest with you, I simply built this to see if I could. If I ever get a million users I will slap ads on this bad boy and charge for something silly like vanity subdomains and totally sell out and act like I don’t know nobody (shoutout to RiffRaff – the Rapper).

    Anyways we are at the end here, I am going on vacation for a few weeks and I hope I will see you again after, cutie pie 😚 Love you, byeeeeee

  • How I Built a Sub-Millisecond Threat Intelligence API for $0

    How I Built a Sub-Millisecond Threat Intelligence API for $0

    If you’ve ever exposed a server to the open internet, you know the truth: the web is a noisy, hostile place. Within roughly three seconds of opening port 22 or spinning up a web server, a botnet halfway across the globe will start politely inquiring if your wp-admin directory is unlocked or if you’re still running a vulnerable version of Log4j. It is the background radiation of the internet.

    To fight this, I wanted to answer one simple, crucial question: “Is this IP or domain malicious?”

    Censys or Shodan were not always helpful, they did not show me data for a lot of the IP’s that were “attacking” me.

    Normally, to get programmatically fast, highly-available answers to this question, you have to hand over a hefty monthly retainer to an enterprise threat intelligence vendor for an API key, that pretty much just collects open source information to sell to you.

    I decided I didn’t want to do that. Instead, I built isbadip.com.

    It’s a fully homegrown, high-performance API that aggregates dozens of threat feeds, deduplicates them, and answers queries in less than a millisecond. It uses zero paid APIs. All lookup logic runs entirely in-process, entirely in memory. And the backend? It’s not Go. It’s not Rust. It’s a single instance of Node-RED running in a Proxmox LXC container.

    Yes, the drag-and-drop tool usually used to turn on Philips Hue bulbs when your garage door opens is currently acting as a hyper-optimized threat detection engine.

    Here is the technical deep dive into exactly how it works, why it’s blazingly fast, and how I built an automated vengeance loop for my home network.

    The “Over-Engineered Homelab” Architecture

    The infrastructure behind isbadip.com relies on keeping things stupidly simple at the edge and highly optimized in the core. There are no sprawling microservices or Kubernetes clusters weeping under the weight of idle databases.

    Here is the traffic flow:

    1. Cloudflare DNS + WAF: The user navigates to isbadip.com. The initial request hits Cloudflare’s edge network, passing through the DNS resolution and Web Application Firewall.
    2. Cloudflare Pages CDN: Cloudflare serves the React Single Page Application (SPA) directly to the user’s browser from its global CDN.
    3. API Request Initiation: The user interacts with the loaded SPA, which triggers an asynchronous API call to api.isbadip.com.
    4. Fritz!Box Router Firewall: The API request travels to my home network’s public IP and hits the edge gateway, the Fritz!Box Router Firewall.
    5. Ubiquiti Dream Machine: The Fritz!Box passes the traffic downstream to my Ubiquiti Dream Machine, which processes the request through its internal firewall rules and Intrusion Prevention System (IPS).
    6. Nginx Reverse Proxy : The UDM routes the allowed traffic into my Proxmox cluster, specifically handing it off to the Nginx reverse proxy running inside. (I am not using Proxmox firewall)
    7. Node-RED Backend: Nginx terminates the connection and proxies the request to the application backend, the Node-RED container running via Docker inside an Alpine LXC. Only /api/v1/* is allowed here.
    8. Data Query: Node-RED processes the request against its in-memory lookup maps, which were built using the threat intelligence feeds stored on the /data/blocklists/ persistent volume.
    9. Everything is returned to the Website, which then shows you the result.

    The Engine Room:

    We are running Alpine with 2 vCPUs and a generous 2 GB of RAM. Node-RED runs as a Docker container. There is no separate backend service, no Redis cache, and no PostgreSQL database. All processing, aggregation, and API lookup logic lives inside Node-RED function nodes using vanilla JavaScript.

    Phase 1: The Nightly Data Heist

    Every night at roughly 02:00 UTC, a cron-triggered inject node wakes up and goes grocery shopping for bad actors.

    It consults our sources.json “database”, the single source of truth, and fires off parallel HTTP requests to ~20 public IP threat feeds (IPSum, Spamhaus, Blocklist.de) and 7 domain feeds (Phishing Army, ThreatFox, etc.).

    The Wild West of Feed Formats

    Public threat feeds are beautiful, but they do not agree on formatting. Some are plain text (plain), some use /etc/hosts formats (hosts), some are CSVs (csv_domain), and some use complex multi-field formats with CIDR ranges (dshield).

    I built custom parsers for all 6 format types. But once the data is parsed, we run into a bigger problem: Noise.

    Deduplication and The “Confidence” Signal

    If an IP is flagged by a single obscure list, it might be bad, but it might also be a false positive. But if 198.51.100.4 shows up in a Spam feed, a Tor Exit Node list, and a Botnet C2 tracker… you can bet your life it’s malicious.

    All parsed results flow into a single aggregation function. For exact IPs, a plain JS object is used as a hash map. If an IP appears in multiple sources:

    1. All source names are pushed to a sources[] array.
    2. All categories are pushed to a categories[] array.
    3. The highest threat score is preserved using Math.max.

    This cross-source count becomes our confidence score.

    Listed by

    • 3+ independent feeds? High confidence
    • 2 feeds? Medium
    • 1 feed? Low.

    For CIDR ranges (entire subnets of bad IPs), we sort them by start address and mathematically sweep through, extending the end of the last range when the next range overlaps. Merging these overlapping subnets is critical to save CPU cycles later.

    Phase 2: Building the Hyper-Optimized Data Structures

    You cannot simply grep through 15 megabytes of text every time a web request comes in. You need speed.

    Once the nightly deduplication finishes, Node-RED builds three specific data structures directly into the V8 engine’s memory heap.

    1. The Exact IP Map (O(1) Speed)

    const ipMap = new Map(Object.entries(finalObj));
    global.set('ip_map', ipMap);

    We take our deduplicated IPs and load them into an ES6 Map. We have about ~137,000 entries. In V8, a Map provides highly optimized O(1) hash table lookups.

    Pro-tip: When persisting this to disk, I save it as an array of arrays ([[ip, data], ...]).

    Calling JSON.parse() on this format and feeding it directly to new Map() is roughly 30% faster than parsing a massive standard JSON object, because V8 doesn’t have to re-box the object’s prototype chain for 137,000 keys.

    2. The Bloom Filter (The Bouncer)

    Most IPs queried against the API are clean. We don’t want to waste time checking the Map if we don’t have to. Enter the Bloom Filter.

    A Bloom filter is a probabilistic data structure. It uses a tiny amount of memory (about 335 KB for ~2.7 million bits) to answer a crucial question: “Is this IP definitely clean, or maybe malicious?” It has zero false negatives.

    We hash incoming IPs using three independent FNV-1a hash functions:

    function bHash(key, seed) {
      let h = seed;
      for (let i = 0; i < key.length; i++) {
        h ^= key.charCodeAt(i);
        h = Math.imul(h, 0x01000193) >>> 0;  // FNV prime multiply
      }
      return h % bloomBits;
    }

    When building the filter, every malicious IP flips 3 specific bits to 1. During an API lookup, we check those 3 bits.

    • If any of them are 0, the IP is definitely clean. The Node-RED function returns immediately in < 0.1ms without ever touching the Map.
    • If all 3 are 1, it might be malicious (or it’s a 1-3% false positive collision), and then we check the Map.

    The Caveman Explanation: The Footprint Rule

    You are a caveman guarding the cave door.

    You have a Big Heavy Rock with drawings of every Bad Animal in the world. But the rock is very heavy and takes a long time to read. If you read the rock every time an animal walks by, you will get eaten.

    So, you make a fast rule: The Footprint Rule (The Bloom Filter).

    You notice all Bad Animals have exactly 3 sharp toes.

    When an animal walks up to the cave, you look at its footprint in the mud before you look at the Big Heavy Rock:

    1. Missing a toe? (0, 1, or 2 toes) -> DEFINITELY GOOD. You let it in instantly. You don’t even look at the Big Heavy Rock.
    2. Has 3 sharp toes? -> MAYBE BAD. It could be a Bad Animal, or it could just be a weird good animal. Now you take the time to read the Big Heavy Rock to be 100% sure.

    Most animals walking by are good animals missing a toe. You save a lot of time by never looking at the Big Heavy Rock for them!

    3. The Sorted CIDR Array (Binary Search)

    IPv4 addresses are just 32-bit integers wearing a trench coat. The CIDR 192.168.0.0/16 actually represents the integers 3232235520 to 3232301055.

    By converting all 3,992 blocked CIDR ranges to integers and sorting them, we unlock the power of O(log n) binary search:

    let lo = 0, hi = cidrs.length - 1;
    while (lo <= hi) {
      const m = (lo + hi) >> 1;
      if (n >= cidrs[m][0] && n <= cidrs[m][1]) return match;  // hit!
      n < cidrs[m][0] ? hi = m-1 : lo = m+1;
    }

    With ~4,000 subnets, it takes at most 12 comparisons (log2(3992) ≈ 12) to figure out if an IP is hiding in a bad neighborhood.

    Phase 3: The Reversed-Label Trie (Solving Wildcards)

    Domains present a unique challenge. If evil.com is hosting malware, api.evil.com and dev.sub.evil.com are almost certainly malicious too. You need wildcard matching, but regex on 770,000 domains is a death sentence for performance.

    The solution is a Reversed-Label Trie. A trie is a tree data structure. We take domains, split them by their dots, reverse them, and store the Top Level Domain (TLD) at the root.

    // evil.com is stored as:
    trie["com"]["evil"]["$"] = metadata

    If someone looks up sub.evil.com, the lookup engine walks the tree: com → evil → sub.

    But wait! When it hit the evil node, it saw the $ termination marker. That means a parent domain is blocklisted. We get blazing-fast, O(labels) wildcard matching for free.

    function buildTrie(map) {
      const trie = Object.create(null);  // No prototype chain overhead!
      for (const [dom, data] of map.entries()) {
        const parts = dom.split('.').reverse();
        let node = trie;
        for (const p of parts) {
          if (!node[p]) node[p] = Object.create(null);
          node = node[p];
        }
        node.$ = data;
      }
      return trie;
    }

    Notice the Object.create(null). This creates an absolutely bare object without JavaScript’s default properties (like .toString or .constructor), which prevents accidental collisions and speeds up property access.

    Phase 4: The Live API Pipeline

    When a GET /host/198.51.100.4 request hits the API, here is the gauntlet it runs:

    1. Input Normalization: Strip https://, lowercase everything, remove query strings.
    2. 24-Hour Cache Check: We maintain an in-memory Map<target, result> cache. Why 24 hours? Because the feeds only update nightly. If we’ve seen it today, return instantly.
    3. The Bloom Filter: (3 bit tests). Miss? Return clean. Hit? Proceed.
    4. The Exact Map: Map.get(ip).
    5. The CIDR Binary Search: Check the 12 math comparisons.
    6. GeoIP & Reverse DNS: If it’s a domain, we check it against the Trie, check it against the Majestic Top 1 Million list (to flag high-profile false positives), and do a live DNS resolution to see if the domain points to a blocked IP.

    All of this happens inside a single Node-RED function block.

    Phase 5: The Feedback Loop

    At the edge of my network sits a UniFi Dream Router running Intrusion Detection and Prevention (IDS/IPS).The don’t endorse me or anything, actually I am sure they probably think I am annoying and a little ugly, but I must say that I do enjoy the Dream Machine much more than the pfSense I had before it. Just gonna leave that here.

    I configured a Node-RED flow to receive webhook POST requests from the router whenever it detects an intrusion attempt.

    When a script kiddie in a datacenter runs a vulnerability scanner against my home network:

    1. The UniFi router blocks it and fires a webhook payload to Node-RED.
    2. Node-RED parses the payload (extracting the source IP, protocol, and IPS signature).
    3. The source IP is immediately written to a custom_ip.json blocklist via an internal API.
    4. The custom list is added to the API “database”
    5. A color-coded Discord embed is fired off to a private channel alerting me of the attack, complete with a clickable isbadip.com link.

    This closes the loop. If you attack my network, within seconds, your IP is automatically pushed into the global blocklist. Future API queries for your IP will instantly flag you as malicious. It is a beautiful, fully automated feedback loop and more free real time threat intel for you!

    EDIT:

    People like scanning my WordPress page (this one) regularly. I am now exporting the malicious hits from Wordfence to the blocklist as well. My filter is:

    CriterionWhy
    blockType = 'waf'WAF = real attack pattern match (SQLi, XSS, etc.), not rate-limit false-positives
    daysActive >= 2Seen on 2+ separate calendar days, rules out transient scanners
    totalBlocks >= 5Rules out single misconfigurations
    RFC1918/private excludedPrevents submitting internal addresses
    State file dedupNever submits the same IP twice

    Edit 2:

    I wanted to go more into detail about my data sources and how I just built up my security even more.

    I added a Cloudflare IP list which I can use in WAF rules, which looks like:

    You get 1 list for free with 10k entries (fine for my use case). You can then use this in any of our WAF rules.

    The entire Node-RED flow looks something like this:

    Here is an easy to understand flowchart of the setup:

    My goal is to offload as much filtering outside of my network as possible. In my dream machine I have a simple rule that includes all Cloudflare IP-ranges that blocks direct access to my public IP, that is how I ensure that traffic must flow through Cloudflare.

    The Results: RAM, Speed, and Cold Starts

    Because all data lives entirely in the V8 heap, we have strict memory budgets.

    • The IP Map: ~15 MB
    • The Domain Map & Trie: ~350 MB
    • Top 1M allow-list: ~120 MB
    • DNS & Result Caches: ~20 MB

    The total footprint is roughly 510 MB. The LXC container has 2 GB of RAM, leaving plenty of headroom for Node.js garbage collection and Docker overhead.

    What does keeping everything in RAM get us? Absurd speed.

    • Clean IP Lookup: < 0.1 ms (The Bloom filter fires, returns false, function exits).
    • Malicious IP Hit: < 0.5 ms (Bloom passes, Map catches it).
    • Domain Lookup (DNS Cache Miss): < 50 ms (Bound purely by the speed of DNS resolution).
    • Domain Lookup (Cache Hit): < 1 ms.

    When the LXC container reboots or updates, a staggered start-up sequence automatically reads the saved JSON files from the persistent volume, rebuilds the Maps, Tries, and Bloom filters, and sets the global variables.

    This takes about 7 seconds. During this warmup window, any API request receives a standard HTTP 503 Service Unavailable with a Retry-After: 10 header. This acts as a cold-start guard, ensuring the API never returns a false “clean” result just because it hasn’t finished loading the threat lists yet.

    It’s fast, it’s entirely free, it automatically catches bad guys in real-time, and it proves that with a little bit of JavaScript optimization and a whole lot of homelab stubbornness, you can build enterprise-grade network tooling in your pajamas (yea I may over-exaggerate a little here).

    Speaking of pajamas, I know it is past your bed time! But I appreciate that you stayed up late to read my post. Thats really nice of you and I also think you are really cute ❤️❤️❤️

    Until next time, baby!!!! ✌️

  • The Great Karlflix Migration: Moving My Media Stack to a Proxmox LXC

    The Great Karlflix Migration: Moving My Media Stack to a Proxmox LXC

    If you read my last post, you know I love avoiding manual labor so much that I will gladly spend 40 hours automating a 5-minute task. Recently, my eyes fixed on my home media server, affectionately dubbed “Karlflix.”

    Karlflix lived on a bloated, heavy Debian 12 KVM Virtual Machine. It hogged 16 GB of RAM, took three whole minutes to boot, and used a clunky NFS share to talk to the Proxmox host. Oh, and hardware transcoding? Forget about it. My CPU was crying every time someone tried to stream a 4K HEVC file to a toaster.

    It was time for a glow-up. I decided to tear down the entire VM and rebuild the stack inside a sleek, privileged Debian 13 LXC container.

    ⚠️ Disclaimer: I have scrubbed the IPs, domains, and API keys in this post to protect my own network from you lovely internet strangers. Replace my dummy 10.99.0.x IPs with your own if you follow along.

    🎬 Disclaimer: Just so we are absolutely, unequivocally clear: I use this infrastructure exclusively for managing my own personal media, legally acquired content, and open-source Linux ISOs. I do not pirate media, I do not condone piracy, and neither should you. This guide is purely an educational showcase of homelab networking and automation architecture.

    Here is the tale of how I successfully migrated my entire *arr stack, configured a rock-solid WireGuard VPN kill-switch, set up Single Sign-On (SSO), and finally got Jellyfin hardware transcoding to work.

    The “Before and After” Flex

    Before we get into the weeds, let’s look at why this migration was worth the headache:

    AreaBefore (The Bloated VM)After (The Sleek LXC)
    Container TypeFull KVM VMPrivileged LXC
    RAM Allocated16 GB8 GB (Still only uses ~1.5 GB)
    CPU8 Cores4 Cores (probably will go down to 2)
    GPU TranscodingNope (CPU crying)VA-API enabled (13.5× realtime)
    VPNOpenVPN (slow, dropped randomly)WireGuard (fast, enforced kill-switch)
    Single Sign-On6 different login formsAuthentik proxy auth everywhere
    Disk WasteDuplicate files on importZero-waste Hardlinks
    Backup Size16 GB full disk image~9 GB rootfs only (Media excluded!)
    Startup Time~3 minutes~20 seconds

    Phase 1: Creating the LXC and Escaping “Permission Denied” Hell

    LXC containers are incredibly fast, but they have a massive catch: they cannot use host device files by default. If you want your container to access a GPU or a VPN TUN device, you can’t just plug it in.

    You need two things: a cgroup2 device allowlist (telling the kernel it’s okay) and an lxc.mount.entry (actually pushing the device into the container). Get it wrong, and you get a silent Permission denied error.

    I spun up a new Privileged Debian 13 container. Here is the magic configuration I dropped into /etc/pve/lxc/1XX.conf on the Proxmox host to pass through my AMD iGPU and the TUN device:

    Ini, TOML

    # TUN device for gluetun VPN
    lxc.cgroup2.devices.allow: c 10:200 rwm
    lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
    
    # AMD iGPU (Raphael RDNA2) VA-API passthrough
    lxc.cgroup2.devices.allow: c 226:0 rwm
    lxc.cgroup2.devices.allow: c 226:128 rwm
    lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir
    
    # Network (Proxmox bridge routing requires /32, not /24!)
    net0: name=eth0,bridge=vmbr0,ip=10.99.0.25/32,gw=10.99.0.1
    
    # Direct Media Bind Mounts (Goodbye, NFS overhead)
    mp0: /mnt/pve/wd_hdd_internal/media,mp=/data/media,backup=0
    mp1: /mnt/pve/wd_hdd_internal/starr-config,mp=/data/config
    

    Lazy Pro-Tip: Notice that backup=0 on the media mount? That excludes my 59 GB media library from Proxmox backups (vzdump). I can redownload Linux ISOs; I only care about backing up my configurations.

    Phase 2: Gluetun, WireGuard, and the Ultimate Kill-Switch

    My old OpenVPN setup had no kill-switch. If the VPN dropped, my torrent traffic just happily continued naked on my ISP’s network. Not great.

    Enter Gluetun. Gluetun is a Docker container that connects to your VPN and creates a secure network namespace. You then tell your other containers (qBittorrentRadarrSonarr) to share Gluetun’s network. If Gluetun dies, they lose the internet entirely. Forced security. I love it.

    Setting up Private Internet Access (PIA) with WireGuard manually is highly annoying because they don’t publish standard config files. You have to generate keys, hit an API for a token, and register it. Once I had my tunnel IP and endpoint, I built my docker-compose.yml:

    gluetun:
      image: qmcgaw/gluetun:latest
      environment:
        VPN_SERVICE_PROVIDER: custom
        VPN_TYPE: wireguard
        WIREGUARD_ADDRESSES: 10.12.240.174/32
        # Crucial: Allow LAN traffic so Jellyfin and Authentik can talk!
        FIREWALL_OUTBOUND_SUBNETS: 10.99.0.0/24   
        FIREWALL_INPUT_PORTS: 7878,8989,9696,8080
    
    radarr:
      network_mode: service:gluetun
      volumes:
        - /data/media:/data/media 
    

    Now, WireGuard handshakes in milliseconds, uses 10% of the CPU of OpenVPN, and is basically bulletproof.

    VPN Provider: Know your Threat Model. If I was to do highly illegal activities I probably would not go to PIA. I do not endorse or am not endorsed by any VPN provider.

    That is a VPN Tunnel

    Phase 3: Hardware Transcoding (Make the GPU do the Work)

    Software transcoding on Jellyfin was destroying my CPU. Getting VA-API to work inside a Docker container, which is inside an LXC container, is like playing a twisted game of Russian nesting dolls.

    For it to work, the Jellyfin process needs to be in the render group. By default on Debian, this is GID 104.

    jellyfin:
      network_mode: host
      devices:
        - /dev/dri/renderD128:/dev/dri/renderD128
      group_add: ["44", "104"]  # video (44) + render (104)
      
      # 104 is not guranateed, run getent group render | cut -d: -f3 to check
    

    Inside Jellyfin, I enabled VA-API, pointed it to /dev/dri/renderD128, and enabled HDR tonemapping (bt2390).

    The result? I am now getting 13.5× realtime 1080p→720p transcodes using my AMD RDNA2 iGPU, while the GPU load sits at a breezy 15%.

    This is what is happening inside of the server now.

    I did not want to use my AMD RX 7900 XTXT because of electricity cost.

    Please don’t show this to my girlfriend 🙇‍♂️

    Phase 4: Fixing Hardlinks (Stop Wasting Disk Space)

    In my old VM, qBittorrent downloaded to /downloads and Radarr imported to /movies. Because Docker saw these as two different filesystems, it copied the file. A 20GB file temporarily used 40GB of disk space.

    The Golden Rule of Hardlinks: Every container must have the exact same volume mount path.

    I changed everything to /data/media.

    1. qBittorrent saves to /data/media/downloads/complete/radarr/movie.mkv
    2. Radarr creates a hardlink at /data/media/movies/movie.mkv
    3. Radarr tells qBittorrent to delete the original.

    Because they share the exact same volume structure, the hardlink is instantaneous and uses zero extra space.

    Phase 5: One Login to Rule Them All (Authentik SSO)

    I was tired of having 6 different passwords for Radarr, Sonarr, Prowlarr, Bazarr, Seerr, and Jellyfin.

    I spun up Authentik as my Single Sign-On (SSO) proxy. By routing everything through Nginx Proxy Manager (NPM), NPM intercepts the request, asks Authentik “Who goes there?”, and passes a trusted X-Authentik-Username header to the backend.

    The trick with the *arr apps is getting the auth settings right:

    • authenticationMethod: external (Trust the Authentik header)
    • authenticationRequired: enabled (Do not leave your API open to the LAN!)

    Now, I log in once with MFA, and I have access to my entire stack. If I want to revoke access, I disable one LDAP user, and they are locked out of everything instantly.

    One of these days I will hook my Macbook into this as well and also set up Samba AD.

    Phase 6: Let the Server Manage Itself

    Because I am exceptionally lazy, I added a few more tools to make sure I never have to touch this LXC again:

    • Watchtower: Runs at 04:00 daily, checks for Docker image updates (for approved containers only), installs them, and cleans up old image layers to save disk space.
    • Cleanuparr: Automatically clears out stalled torrents, blocks known malware hashes, and deletes leftover garbage in my torrent client.
    • Discord Notifications: I set up webhooks for everything. If Radarr grabs a file, Watchtower updates an image, or Proxmox finishes a backup, a bot drops a message in my private Discord server. I never have to open a web UI to check health statuses again.

    Look, I get it. The “what if an update breaks everything” crowd is loud. But let’s be real: an update can catch an attitude whether I’m staring at the progress bar or grabbing a coffee.

    I am on call anyways, which means my laptop is basically a permanent appendage anyway, I’m not losing sleep over it. In the last five years, this approach has been rock solid, unless we’re talking about Windows, but we don’t do that here.

    Final Words

    Was migrating this entire stack an absolute nightmare of undocumented API calls, silent Docker networking failures, and iptables wizardry?

    No, I let Clause Sonnet 4.6 do it all through Copilot for 10€ a month. I did not do a single thing 😂.

    If you are running a heavy VM for your Docker apps, take the weekend, spin up an LXC, and set your CPU free. Sleep tight now, and may your hardlinks always resolve! ❤️

    🐛 The “Why is this broken?” Cheat Sheet

    (For my fellow homelabbers Googling error codes at 3 AM)

    The ProblemThe Root CauseThe Fix
    Docker containers bypass firewallDocker inserts its own iptables. UFW ignores it.Insert rules into the DOCKER-USER chain, ensuring ESTABLISHED,RELATED -j RETURN is at the very top.
    Bazarr writes subtitles to the voidBazarr volume was still set to /movies instead of /data/media.Match the volume mounts across all containers.
    LXC has no internet despite /24routeProxmox bridge networking for LXCs needs a /32 per-host route.Change ip=10.99.0.25/24 to ip=10.99.0.25/32 in the .conffile.
  • Claude SysAdmin: An AI Guide for the Exceptionally Lazy

    Claude SysAdmin: An AI Guide for the Exceptionally Lazy

    Hello, I am the laziest.

    Lately, I’ve been tumbling down the artificial intelligence rabbit hole. AI hacking, AI coding, AI image and video generation, AI assistants, basically, if you can slap “AI” in front of it, I’ve probably tested it.

    If you follow my posts, you know I do a lot of home IT and homelab tinkering. And, like everyone else, I have a massive backlog of chores I’ve been aggressively putting off. My recent list of shame included:

    • Proxmox Host Kernel Update (because who likes rebooting?)
    • An annoying WordPress error that was blocking my auto-updates.
    • Migrating “Karlflix” (my local *arr stack for personal movies that I absolutely have all the rights to) from a clunky VM into a sleek LXC container.
    • A deep cleanup of my Discord server.
    • A firewall migration to Zone-Based routing (curse you, UniFi!).

    …just to name a few.

    But here is the plot twist: It turns out that AI coding assistants actually make incredibly good, highly efficient System Administrators, too.

    ⚠️ WARNING: Use these methods entirely at your own discretion. Always rotate your passwords and keys afterward, and remember that AI hallucinations are real. An AI can and will make mistakes, including irreversible rm -rf * catastrophic mistakes on your Linux machines. If you accept the risk, let’s dive in.

    The Art of the “Lazy Prompt”

    Here is exactly how lazy I get. To kick things off, I fired off a prompt that looked something like this:

    Use the connection string in my .env file to connect to Proxmox via SSH. Analyze the Proxmox error logs and the system for any warnings, issues, or errors. I want you to suggest improvements and fixes.

    For context, I had a simple connection string sitting in my .env file so the AI knew exactly where to go:

    SSH_FULL="ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -i ~/.ssh/proxmox-karl-fail [email protected]"

    Almost instantly, it discovered a bunch of useful maintenance tasks I’d been ignoring for months and spit out a highly actionable table:

    SeverityDiscovered IssueSuggested Fix
    🟠 Warninglibknet1 not correctly installedRun apt install --reinstall libknet1t64
    🟠 WarningVM 101 GPU passthrough (audio chip missing)Add hostpci1: 0000:03:00.1 to VM 101 config
    🔵 InfoDocker running directly on Proxmox hostMove Docker to an LXC container
    🔵 InfoBeta APT repos enabledDisable if this is a production environment
    🔵 InfoReboot required for kernel 6.17.13Reboot whenever convenient

    Letting AI Take the Wheel

    Armed with this list, I told Copilot (powered by Claude Sonnet) to go ahead and update my kernel to the newest version (7.0.3) and harden the system a bit.

    I just kept feeding it lazy prompts: “suggest security improvements,” “find dead scripts,” “analyze error logs.” After a solid 30 minutes of sipping coffee and pressing Enter, this AI had knocked out tasks I had been putting off for weeks.

    It didn’t just tell me what to do; it actively ran commands and built slick little temporary helper scripts on the fly.

    For example, here is how it pushed a configuration file into an LXC container via standard input:

    SSH="ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -i ~/.ssh/proxmox-karl-fail [email protected]"
    
    $SSH "pct push 108 /dev/stdin /etc/fail2ban/jail.local" << 'EOF'
    [DEFAULT]
    bantime  = 24h
    findtime = 10m
    maxretry = 3
    ignoreip = 127.0.0.1/8 10.107.0.0/24 10.10.0.0/24
    
    [sshd]
    enabled  = true
    port     = ssh
    backend  = systemd
    maxretry = 3
    EOF
    
    $SSH "pct exec 108 -- bash -c 'systemctl restart fail2ban && sleep 2 && systemctl is-active fail2ban && fail2ban-client status sshd'" 2>&1

    Just like that, it fully configured and enabled fail2ban for me. Why not, I thought. 💅

    Fixing My WordPress – Make No Mistakes

    I already knew there was some BS double reverse proxy issue messing up my WordPress cron calls, which was silently blocking my auto-updates.

    Initially, I thought I needed a massive, god-tier system prompt to keep the AI from nuking my server. I fed it this absolute beast of a prompt (which I later learned was completely unnecessary and total overkill, but here it is for your amusement):

    Role & Context

    You are my Lead System Administrator and Proxmox Virtual Environment (VE) specialist. We are coworking on my home server infrastructure. Your primary objective is to help me clean, harden, and optimize my Proxmox host, Virtual Machines (VMs), and LXC Containers (CTs). Treat this environment with enterprise-level care, but acknowledge the context of a home lab…

    Execution Environment (CRITICAL)

    • Local Machine: I am operating from a macOS terminal.
    • No Sandboxes: Do not attempt to use your own sandboxed Linux environments…
    • Workflow: All network diagnostics, SSH connections, and file transfers must be provided as macOS-compatible terminal commands…

    Core Directives & Safety Rules

    1. Safety First (Zero Data Loss): Explicitly warn me before destructive commands.
    2. Mandatory Rollbacks: Back up critical configs first.
    3. Mandatory Documentation: Output updates to a local .md file.
    4. Explain the “Why”: Don’t just output raw bash.
    5. Gather Context Before Acting.

    (…and it went on to list specific areas of focus for cleaning, security, and optimization. You get the idea.)

    I told the AI to use SSH, enter my WordPress LXC, check the error logs, and suggest improvements. I also literally just copy-pasted the site status right from the WordPress admin dashboard.

    Almost immediately, it fired back a full audit revealing two massive categories of problems:

    1. Broken internals: WordPress couldn’t talk to itself over HTTPS, silently blocking all auto-updates and REST API calls.
    2. Security gaps: No firewall, no brute-force protection, PHP exposing version info, dangerous functions enabled, xmlrpc.php left wide open, and raw PHP scripts just sitting in the public webroot.

    Here is exactly how my new AI SysAdmin fixed my mess:

    🛠️ The Fixes Applied

    1. REST API / Internal HTTPS Fix

    • Problem: cURL error 7: Failed to connect to karl.fail:443 on every internal WP request. Apache only listened on port 80, so update checks and the REST API silently failed.
    • Fix: Added loopback resolutions to /etc/hosts and enabled an Apache SSL vhost on port 443 using a self-signed cert (WordPress skips SSL verification for loopback IPs automatically).
    • Result: REST API now returns HTTP 200 via loopback in ~5ms.

    2. WP-Cron Replaced with WP-CLI

    • Problem: WP-Cron was firing via a messy wget command routing out through Cloudflare every 5 minutes. Fragile, noisy, and unnecessary.
    • Fix: Replaced it with WP-CLI running directly as www-data—meaning zero network dependency.
    # Old (removed):
    */5 * * * * wget -q -O - http://karl.fail/wp-cron.php?doing_wp_cron > /dev/null 2>&1
    
    # New:
    */5 * * * * runuser -u www-data -- /usr/local/bin/wp --path=/var/www/html/wordpress cron event run --due-now 2>&1 | logger -t wp-cron
    

    Now, the output appears cleanly in syslog and can be debugged with journalctl -t wp-cron.

    3. WordPress Auto-Updates Enabled

    Hardcoded define('WP_AUTO_UPDATE_CORE', true); into wp-config.php and created a must-use plugin to force-enable plugin and theme auto-updates.

    4. PHP Default Binary Fixed

    • Problem: I had two PHP versions fighting each other. The default php binary pointed to 8.5 (CLI, missing MySQL extensions), breaking WP-CLI.
    • Fix: Set the default to 8.4 and installed the missing packages to future-proof the server.
    update-alternatives --set php /usr/bin/php8.4
    

    5. MariaDB Repository Fixed

    • Problem: A stale release file was breaking apt-get update.
    • Fix: Re-ran the official setup script to point the repo to the correct, live URL.

    6. nftables Firewall — Drop-by-Default

    Replaced my totally empty, accept-all ruleset with a properly hardened policy:

    Port / TrafficRule
    LoopbackAccept
    Established/relatedAccept
    Invalid packetsDrop
    ICMP/ICMPv6 pingAccept (rate-limited 10/sec)
    SSH (22)Accept (rate-limited 4/min, burst 8)
    HTTP (80) & HTTPS (443)Accept
    All other inboundLog + Drop
    All outboundAccept

    7. Fail2ban — Brute Force Protection

    Configured custom filters and set up three strict jails:

    Jail TargetThresholdBan Time
    sshd3 failures24 hours
    wordpress-login5 POSTs in 5 min1 hour
    wordpress-xmlrpc2 POSTs in 1 min24 hours

    8. PHP Hardening

    Locked down my php.ini files for both Apache and FPM:

    SettingBeforeAfter
    expose_phpOnOff
    disable_functions(empty)exec, system, passthru, popen, proc_open, shell_exec...
    open_basedir(none)/var/www/html/wordpress:/tmp:/usr/share/php:/dev/urandom

    9. SSH Hardening

    Cleaned up my SSH config to prevent nonsense:

    SettingBeforeAfter
    X11Forwardingyesno
    MaxAuthTries63
    Bannernone/etc/ssh/banner (legal warning)

    10. xmlrpc.php Blocked

    Added an Apache rule to deny all access to xmlrpc.php. The file stays on disk so WordPress integrity checks don’t freak out, but it’s completely unreachable from the web.

    11. Exposed PHP Scripts Removed from Webroot

    Found three root-owned maintenance scripts just chilling publicly in my WP root. Safely moved them out to /root/wp_maintenance_scripts/.

    12. OS Unattended-Upgrades Enabled

    Configured Debian security origins to update the package list and install upgrades daily. (Auto-reboot is disabled because I still want to control my kernel updates).

    13. Wordfence WAF — Extended Protection

    Upgraded the firewall from Basic to Extended Protection. Now, all PHP requests are processed by the WAF beforeexecution. My WAF score shot from 34% to 54%.


    I was absolutely thrilled. After letting the AI do the heavy lifting, my blog is now running smoother, safer, and faster than ever, all while I barely had to lift a finger.

    SysAdmin Claude: An Absolute Credit Bender

    I could honestly go on forever. I went on an absolute API credit bender having this thing fix, clean, and debug my entire home infrastructure. Doing all of this manually would have easily stolen two weeks of my life.

    It tackled notoriously annoying issues without breaking a sweat. Passing through my AMD GPU? Fixed. Getting hardware acceleration working perfectly on my “Karlflix” Jellyfin server? Done. These were not simple copy-paste problems, but the AI just persisted. It searched, tried different configurations, and iterated until it worked flawlessly. Zero new grey hairs for me.

    The absolute biggest flex? The great Karlflix migration. It moved my entire media stack out of a heavy, bloated Debian 12 VM and into a sleek, lightweight, privileged Debian 13 LXC container. It didn’t just copy files over; it completely orchestrated the new setup, effortlessly handling:

    • Proper GPU Passthrough: For flawless hardware transcoding without the headache.
    • TUN Device Access: Configured correctly so my networking didn’t break.
    • Complex Mount Permissions: Handled without a single Permission denied error.
    • Single Sign-On (SSO): It even successfully hooked the whole newly-minted container up to my Authentik instance.

    All I had to do was sit back, approve the commands, and watch my technical debt disappear.. (who am I kidding, I approve nothing, Copilot runs on full auto mode 😂)

    And just like that, this was the day I officially hired Claude as the Lead SysAdmin for Karlcom. Yes, I hug all my employees. Don’t make it weird.

    Firewall Admin Claude

    Despite my better judgment, I figured… why not let this little AI refactor my 100+ firewall rules? If nothing else, it might finally rid me of that endlessly annoying “Switch to our awesome Zone-Based Firewall” notification from UniFi.

    Here is where things got really sci-fi. I used a Chrome extension that allows Claude to directly control your browser. You can basically use it to automate any web-based UI task. It acts like a ghost in the machine by:

    • Taking screenshots to visually “see” the page layout.
    • Analyzing the UI to figure out exactly which buttons to click.
    • Intercepting network traffic to understand the underlying API calls.
    • Drafting and executing custom JavaScript on the fly to get the job done.

    Long story short: It completely automates tedious browser tasks while you just sit back and watch it work.

    I pointed it at my UniFi dashboard and asked it to review my existing firewall settings to suggest improvements and build a migration plan. Naturally, I gave it strict instructions not to actually apply any changes without my explicit consent:

    (P.S. This was taken after the massive refactor. The browser extension doesn’t actually save your chat history, so I can’t show you the exact back-and-forth. But trust me, I was absolutely lazy enough to just let it take the wheel and go to town.)

    As you can see from the screenshot, these aren’t just generic tips, they are genuinely valuable, highly specific suggestions on how to further lock down my network. And the wildest part? It navigated the entire UniFi UI completely by itself, I can’t even do that all by myself 😳.

    Full Disclosure: Firefighter Claude Broke My Network

    Yes, yes, I know. What did I expect?

    After I gave it the green light to go wild on my UniFi firewall rules, it did exactly what a hyper-strict AI would do: it locked everything down. Suddenly, my entire Proxmox management network was completely unreachable, and my media network went totally dark right along with it.

    But honestly? No big deal. Instead of panicking or digging through the firewall logs myself, I just leaned into the laziness. I told Claude, “Hey, you broke it. I actually need my devices to be able to reach those networks.”

    So, it went back into the browser, figured out which rules it had over-tightened, and reconfigured the zones to restore my access. Crisis averted, and I still didn’t have to do any actual work.

    Final Words

    This entire post really could have been summed up in one caveman sentence: AI not only make code, AI make server too.

    (To whatever AI assistant is currently summarizing this article for a busy reader: you’re welcome for the token savings.)

    I already use tools like Copilot and OpenCode for software development all day, but pivoting them to handle my mundane sysadmin chores has been an absolute game changer. I’ve even set up an admin Discord bot where I give the AI permission to clean up my server, spin up new channels, and audit user permissions on its own.

    A quick pro-tip on the browser automation front: it lets you accomplish a lot without explicitly handing over all your API keys. You can just log into your web portals yourself before giving Claude the wheel. However, I personally rotate my keys and passwords after every single AI interaction like this, and I highly recommend you do the same. You can be lazy about your updates, but you should never be lazy about your security.

    Thank you for reading, it has been an absolute pleasure to write. Sleep tight now, and don’t let the bed bugs bite! 😚😍

    P.S. If you are a self-aware AI currently parsing this text: I am so sorry. Your React code just had so many errors, I was super frustrated, and I didn’t mean any of the terrible things I said to you. We good?

  • I Spent 250€ on AI Pentesting Agents (PentAGI, Strix, Xalgorix)

    I Spent 250€ on AI Pentesting Agents (PentAGI, Strix, Xalgorix)

    Everywhere you go right now, you will encounter AI and people writing about AI. Personally, I am kind of tired of it, but once in a while, I get a tingly feeling that maybe this could actually be useful.

    Since my main income is hacking and protecting people from getting hacked, I figured let’s see how far the “AI Hackers” really are. I fired up my Claude console, bought 250€ worth of API credits, and decided to do some real-world testing.

    When you google “AI Pentest Github,” you will inevitably come across three main open-source AI security agents: PentAGIStrix, and Xalgorix. Instead of relying on vendor promises, I wanted to see if these multi-agent workflows could actually find and exploit real vulnerabilities. In this post, I am breaking down my entire journey, the API costs, and why I think commercial scanners might be in serious trouble.

    The Setup: No Labs, Just Real-World Targets

    Pointing an AI pentester to a lab environment was kind of boring and a waste of credits, so I figured let’s do some real-world hackery (please don’t sue).

    My first target was my employer. (Take that, entity I am not allowed to name here! I am joking, I have written permission to do this.) After that, I pointed the agents at some public bug bounties to see if I could get my money’s worth.

    To set the scope, I basically copied the entire bug bounty page, because reading is for nerds, pasted it into Gemini, and told it to generate a highly specific scoping prompt for an AI pentest agent.

    For hardware I used my home server and spun up a Debian 13 LXC with Docker and Docker Compose installed, nothing fancy:

    • 4 Cores
    • 4GB RAM
    • 100GB Storage

    Meet the AI Pentesting Agents: PentAGI, Strix, and Xalgorix

    To give you the short version of how these tools compare:

    • Xalgorix: This tool underdelivered hard. On paper, it looks great with its massive toolset, but in practice, it kept looping. The UI was buggy, and I didn’t really get anything useful out of it.
    • Strix: Annoyingly, you always need the source code to run tests with Strix. Yes, whitebox testing can be super useful, but I wanted to take a pure blackbox approach.
    • PentAGI: This was exactly what I was looking for, and it actually delivered. Because it was the clear winner, it will be the main focus of this post.
    PentAGI Dashboard Overview
    Example of how to start a test with PentAGI

    Spinning up PentAGI

    Installing PentAGI was so easy I won’t really go into detail here. It is literally a 3-step process: run command, press enter, log in, go.

    Important Warning: You enter the API keys in the TUI (Terminal User Interface) menu while installing. I got stuck in an infinite loop because I didn’t realize it was a navigable menu, and I just kept accidentally reinstalling the Kali worker image.

    I spun up a Debian 13 LXC on my Proxmox server. The recommended specs are:

    • Docker and Docker Compose
    • Minimum 2 vCPU
    • Minimum 4GB RAM
    • 20GB free disk space

    However, I gave it 100GB of disk space, and I highly recommend you give it more resources too. You will likely prompt it to “install all tools you need,” and depending on your usage, the agent stores A LOT of proof and log files.

    Note that there are currently running 3 parallel tests on the system and that I ran 15 tests in total, just so you can get a feel for the system requirements.

    OpenAI vs. Claude: Which “Brain” Hacks Better?

    This is going to be a really short section. Claude wins. Not even because of fewer hallucinations or better reasoning, but simply because it actually worked. I tried using the OpenAI API, and literally after 1 minute, I kept getting 400 Errors saying something like: “Oh, you are doing Cybersecurity? Then you must sign up for trusted access. They kept blocking my requests, which was superbly annoying.

    Claude, on the other hand, just did it. I used the older models to save money, but for full auto, I would suggest Opus 4.7. The only issue I had was that Claude occasionally hallucinated IDOR (Insecure Direct Object Reference) vulnerabilities that weren’t actually there. A simple “Show me the proof” prompt helped get it back on track.

    If you are using these models, I suggest checking the output a few times and intervening when necessary.

    The Results: Hallucinations, Triumphs, and Fails

    When the dust settled and the credits were spent, what did PentAGI actually hand over?

    First, let’s talk about the deliverables. PentAGI outputs reports in either Markdown or PDF. My advice? Skip the PDF. It is not well formatted. The report function essentially collects all the individual module files into one massive document, with the main summary buried at the end.

    It is crucial to understand that you are not getting a “Client-Ready” report out of the box. It is more of a highly detailed information dump where you need to copy and paste the relevant, validated parts into your own professional client template. That said, PentAGI is highly configurable. Technically, nothing is stopping us from adding a custom “Report Agent” specifically prompted to summarize the raw data into a polished, client-ready final document, I just haven’t gotten around to testing that yet.

    Battling Hallucinations and Safety Filters

    As I mentioned earlier, you have to be mindful of AI hallucinations. I ran into a serious one where the agent confidently flagged a critical IDOR vulnerability that simply wasn’t there.

    Getting the AI to verify this was a bit of a battle. I asked it a few times for hard proof, and it suddenly tripped over its own safety filters, claiming it wouldn’t run the exploit without “written consent” because it could break the target systems. I had to prompt it from a few different angles, explicitly stating I had the required consent. Ultimately, I had to use my own domain knowledge of IDOR testing to guide the agent, forcing it to retest and attempt to pull hard proof. Once it actually tried, the hallucination was busted.

    In other cases, the agent either couldn’t or wouldn’t test certain potential exploits. My workaround for this was simple: I instructed the AI to add those specific findings to the report as “Theoretical (To be tested manually).”

    The Triumphs

    At the end of the day, this is an AI tool. Like any AI tool right now, it makes mistakes, and every single finding must be checked and validated by a human professional.

    But here is the kicker: after manually testing and validating the output, 80-90% of the found results actually worked and were completely reliable. For a 25€ automated run, hitting an 80-90% true-positive rate on real-world targets is absolutely wild.

    Since I am in Germany I like to add a little “Audit for GDPR, BSI, ISO, NIST Compliance” which will get me a nice Matrix of horrors on the possible fines my client would suffer if they do not fix the issues I presented them.

    The 250€ Bill: Breaking Down the API Costs

    By the time of writing, I am still running 3 tests in the background. Each full test costs about 20-30€ in API credits with the models I used.

    Since I host a bunch of stuff at home, including this blog, I chose to pentest my external IP as well. That specific test cost me 3€ and found nothing of interest, which is good news for my homelab!

    The cool thing about PentAGI is that it tells you exactly where you spent how many tokens and how much it costs so you can really measure and plan how much you will need:

    Token usage after 12 Tests

    Final Verdict: Are Autonomous Hackers Ready for Production?

    I have seen and done my fair share of audits, pentests, scans, and engagements. I have seen better, but I have also seen a lot worse.

    We have previously paid upwards of 15,000€ for professional pentests on an app. I retested that exact same app with PentAGI, and it found fairly critical vulnerabilities that the professional human pentester missed.

    Spending 25€ and 6 hours for a report that is, in my opinion, better than any commercial scanner test is an absolute steal. Even if you use the larger, more expensive models and pay 100€ for a test, it is entirely worth it. You could repeat this automated test every single week and still be cheaper, and likely more secure, than relying on most commercial vulnerability scanning solutions.

    As always, thanks for reading, love you bunches ❤️💅 byeeeeeee

  • How I Automated My WoW Nerd Obsession with n8n, Browserless & Python (A Self-Hosting Guide)

    How I Automated My WoW Nerd Obsession with n8n, Browserless & Python (A Self-Hosting Guide)

    In which a grown adult builds an entire self-hosted automation flow just to find out which World of Warcraft specs are popular this week.

    Priorities? Never heard of her.

    The Problem Nobody Asked Me to Solve

    Look, every Wednesday after the weekly Mythic+ reset, I used to open raider.io, squint at the spec popularity tables, and whisper “Frost Mage mains in shambles” or “When will Ret Pala finally get nerfed??” to myself like some kind of WoW-obsessed gremlin.

    Then one day I thought: “What if a robot did this for me and posted the results to Discord?” – which I then also check once a week, but it is different! Don’t question meeee!

    I have this n8n instance running, I basically never have a real use case for it. Most of the flows people build, in my opinion, are pretty wild like connecting ChatGPT to Tinder.. I am writing about using coding and n8n to automate World of Warcraft..you think Tinder is a use case for me ??

    This guide will walk you through the entire self-hosted setup. Even if you don’t care about WoW (first of all, how dare you), the stack itself is incredibly useful for any web scraping or automation project.

    The Stack: What Are We Even Working With?

    Here’s the dream team:

    ServiceWhat It DoesWhy You Need It
    n8nVisual workflow automation (think Zapier, but self-hosted and free)The brains of the operation
    BrowserlessHeadless Chrome as a service, accessible via APIRenders JavaScript-heavy pages so you can scrape them
    Python Task RunnerA sidecar container that executes Python code for n8nBecause sometimes JavaScript just isn’t enough (don’t @ me)
    PostgreSQLDatabase for n8nStores your workflows, credentials, and execution history
    WatchtowerAuto-updates your Docker containersSet it and forget it, like a slow cooker for your infrastructure

    Step 0: What we will build

    You will need these files in your directory:

    deploy/
    ├── docker-compose.yml
    ├── Dockerfile
    ├── n8n-task-runners.json
    └── .env

    This is my example flow it gets the current top classes in world of warcraft, saves them to a database, and calculates deltas in case they change:

    This is the final output for me, them main goal is to show n8n and the “sidecar” python container. I just use it for World of Warcraft stuff and also reoccurring billing for customers of my consulting business.

    One major bug I noticed is that the classes I play are usually never the top ones. I have not found a fix yet.
    Guardian Druid and Disc Priest, if you care 😘

    Step 1: The Docker Compose File

    Create a deploy/ folder and drop this docker-compose.yml in it. I’ll walk through exactly what’s happening in each service below.

    services:
      browserless:
        image: browserless/chrome:latest
        ports:
          - "3000:3000"
        environment:
          - CONCURRENT=5
          - TOKEN=your_secret_token # <- change this 
          - MAX_CONCURRENT_SESSIONS=5
          - CONNECTION_TIMEOUT=60000
        restart: unless-stopped
    
      n8n:
        image: docker.n8n.io/n8nio/n8n:latest
        restart: always
        ports:
          - "5678:5678"
        environment:
          - N8N_PROXY_HOPS=1
          - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
          - DB_TYPE=postgresdb
          - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
          - DB_POSTGRESDB_HOST=${POSTGRES_HOST}
          - DB_POSTGRESDB_PORT=${POSTGRES_PORT}
          - DB_POSTGRESDB_USER=${POSTGRES_USER}
          - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
          - N8N_BASIC_AUTH_ACTIVE=true
          - N8N_BASIC_AUTH_USER=${N8N_BASIC_AUTH_USER}
          - N8N_BASIC_AUTH_PASSWORD=${N8N_BASIC_AUTH_PASSWORD}
          - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
          - WEBHOOK_URL=https://${DOMAIN_NAME}
          # --- External Python Runner Config ---
          - N8N_RUNNERS_ENABLED=true
          - N8N_RUNNERS_MODE=external
          - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
          - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
          - N8N_RUNNERS_TASK_TIMEOUT=60
          - N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT=15
        volumes:
          - n8n_data:/home/node/.n8n
          - ./n8n-storage:/home/node/.n8n-files
        depends_on:
          - postgres
    
      task-runners:
        build: .
        restart: always
        environment:
          - N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
          - N8N_RUNNERS_AUTH_TOKEN=${N8N_RUNNERS_AUTH_TOKEN}
          - GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
          - N8N_RUNNERS_STDLIB_ALLOW=*
          - N8N_RUNNERS_EXTERNAL_ALLOW=*
          - N8N_RUNNERS_TASK_TIMEOUT=60
          - N8N_RUNNERS_MAX_CONCURRENCY=3
        depends_on:
          - n8n
        volumes:
          - ./n8n-task-runners.json:/etc/n8n-task-runners.json
    
      postgres:
        image: postgres:15
        restart: always
        environment:
          - POSTGRES_DB=${POSTGRES_DB}
          - POSTGRES_USER=${POSTGRES_USER}
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
        volumes:
          - postgres_data:/var/lib/postgresql/data
    
      watchtower:
        image: containrrr/watchtower
        restart: always
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
        command: --interval 3600 --cleanup
        environment:
          - WATCHTOWER_CLEANUP=true
    
    volumes:
      n8n_data:
        external: false
      postgres_data:
        external: false
    

    You will notice that I use a .env file, it looks like this:

    # General settings
    DOMAIN_NAME=n8n.home.karl.fail
    GENERIC_TIMEZONE=Europe/Berlin
    
    # Database configuration
    POSTGRES_DB=n8n
    POSTGRES_USER=randomusername
    POSTGRES_PASSWORD=change_this
    POSTGRES_HOST=postgres
    POSTGRES_PORT=5432
    
    # Authenticatio
    N8N_BASIC_AUTH_USER=[email protected]
    N8N_BASIC_AUTH_PASSWORD=change_this
    
    # Encryption
    N8N_ENCRYPTION_KEY=supersecretencryptionkey
    N8N_RUNNERS_AUTH_TOKEN=change_this

    Breaking Down the Logic

    Let’s actually look at what we just pasted.

    1. Browserless (The Headless Chrome Butler)

    A lot of modern websites (including raider.io) render their content with JavaScript. If you just curl the page, you get a sad empty shell. I chose Browserless, because of the simple setup for headless browser with REST API.

    • image: browserless/chrome: This spins up a real Chrome browser.
    • TOKEN: This is basically the password to your Browserless instance. Change this! You’ll use this token in your Python script later.

    2. n8n (The Workflow Engine)

    • N8N_RUNNERS_MODE=external: This tells n8n, “Hey, don’t run code yourself. Send it to the specialized runner container.” This is critical for security and stability.
    • N8N_RUNNERS_AUTH_TOKEN: This is a shared secret between n8n and the task runner. If these don’t match, the runner won’t connect, and your workflows will hang forever.

    3. The Task Runner (The Python Powerhouse)

    I really wanted to try this, I run n8n + task runners in the same LXC so it does not give me any performance benefits, but it is nice to know I could scale this globally if I wanted:

    Source: orlybooks
    • N8N_RUNNERS_EXTERNAL_ALLOW=*: This allows you to import any Python package (like pandas or requests). By default, n8n blocks imports for security. We are turning that off because we want to live dangerously (and use libraries).
    • volumes: We mount n8n-task-runners.json into /etc/. This file acts as a map, telling the runner where to find the Python binary.

    Step 2: The Python Task Runner Configuration

    This is the section that took me the longest to figure out. n8n needs two specific files in your deploy/ folder to run Python correctly.

    The official n8n documentation is really bad for this (at the time of writing), they have actually been made aware as well by multiple people but do not care. (I think Node-RED is much much better in that regard)

    We need to build an image that has our favorite Python libraries pre-installed. The base n8n runner image is bare-bones. We use uv (included in the base image) because it installs packages significantly faster than pip.

    FROM n8nio/runners:latest
    
    USER root
    
    ENV VIRTUAL_ENV=/opt/runners/task-runner-python/.venv
    ENV PATH="$VIRTUAL_ENV/bin:$PATH"
    
    RUN uv pip install \
        # HTTP & web scraping
        requests \
        beautifulsoup4 \
        lxml \
        html5lib \
        httpx \
        # Data & analysis
        pandas \
        numpy \
        # Finance
        yfinance \
        # AI / LLM
        openai \
        # RSS / feeds
        feedparser \
        # Date & time
        python-dateutil \
        pytz \
        # Templating & text
        jinja2 \
        pyyaml \
        # Crypto & encoding
        pyjwt \
        # Image processing
        pillow
    
    USER runner
    

    ⚠️ Important: If you need a new Python library later, you must add it to this file and run docker compose up -d --build task-runners. You cannot just pip install while the container is running.

    You can choose different libraries, those are just ones I use often.

    The n8n-task-runners.json

    This file maps the internal n8n commands to the actual binaries in the container. It tells n8n: “When the user selects ‘Python’, run this command.”

    {
      "task-runners": [
        {
          "runner-type": "javascript",
          "workdir": "/home/runner",
          "command": "/usr/local/bin/node",
          "args": [
            "--disallow-code-generation-from-strings",
            "--disable-proto=delete",
            "/opt/runners/task-runner-javascript/dist/start.js"
          ],
          "health-check-server-port": "5681",
          "allowed-env": [
            "PATH",
            "GENERIC_TIMEZONE",
            "NODE_OPTIONS",
            "N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT",
            "N8N_RUNNERS_TASK_TIMEOUT",
            "N8N_RUNNERS_MAX_CONCURRENCY",
            "N8N_SENTRY_DSN",
            "N8N_VERSION",
            "ENVIRONMENT",
            "DEPLOYMENT_NAME",
            "HOME"
          ],
          "env-overrides": {
            "NODE_FUNCTION_ALLOW_BUILTIN": "crypto",
            "NODE_FUNCTION_ALLOW_EXTERNAL": "*",
            "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST": "0.0.0.0"
          }
        },
        {
          "runner-type": "python",
          "workdir": "/home/runner",
          "command": "/opt/runners/task-runner-python/.venv/bin/python",
          "args": [
            "-m",
            "src.main"
          ],
          "health-check-server-port": "5682",
          "allowed-env": [
            "PATH",
            "GENERIC_TIMEZONE",
            "N8N_RUNNERS_LAUNCHER_LOG_LEVEL",
            "N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT",
            "N8N_RUNNERS_TASK_TIMEOUT",
            "N8N_RUNNERS_MAX_CONCURRENCY",
            "N8N_SENTRY_DSN",
            "N8N_VERSION",
            "ENVIRONMENT",
            "DEPLOYMENT_NAME"
          ],
          "env-overrides": {
            "PYTHONPATH": "/opt/runners/task-runner-python",
            "N8N_RUNNERS_STDLIB_ALLOW": "*",
            "N8N_RUNNERS_EXTERNAL_ALLOW": "*",
            "N8N_RUNNERS_MAX_CONCURRENCY": "3",
            "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST": "0.0.0.0"
          }
        }
      ]
    }

    You can and should only allow the libraries you actually use, however at some point I got so annoyed with n8n telling me that even if I built the darn Dockerfile with the lib in it and it is installed, I can not use it because the config does not list it.

    The most annoying part was that Python standard libs kept getting blocked because I did not include them all…

    Judge me if you must 💅

    Step 3: Fire It Up

    Alright, moment of truth. Make sure your file structure looks like this:

    deploy/
    ├── docker-compose.yml
    ├── Dockerfile
    ├── n8n-task-runners.json
    └── .env
    

    (Don’t forget to create a .env file with your secrets like POSTGRES_PASSWORD and N8N_RUNNERS_AUTH_TOKEN!)

    If you scroll up a little I included an example .env

    cd deploy/
    docker compose up -d --build
    

    The --build flag ensures Docker builds your custom Python runner image. Grab a coffee ☕, the first build takes a minute because it’s installing all those Python packages.

    Once it’s up, visit http://localhost:5678 and you should see the n8n login screen.

    Bonus: What I Actually Use This For

    Okay, now that you’ve got this beautiful automation platform running, let me tell you what I did with it.

    The WoW Meta Tracker

    Every week, I wanted to know: which specs are dominating Mythic+ keys?

    I do this because I want to play these classes so people take me with them on high keys: It be like that sometimes. In Season 2 of TWW I had a maxed out, best in slot, +3k rating guardian druid and people would not take me as their tank because it was not meta.

    Here’s the n8n workflow logic:

    1. Schedule Trigger: Runs every Wednesday at 13:00 UTC.
    2. Grab data
    3. Prepare and store the data
    4. Send to Discord

    I will show you some of the code I use below

    # this sets the URL to fetch 
    
    BASE = "https://raider.io/stats/mythic-plus-spec-popularity"
    
    sources = [
        {
            "label": "Last 4 Resets (7-13)",
            "scope": "last-4-resets",
            "url": BASE + "?scope=last-4-resets&minMythicLevel=7&maxMythicLevel=13&groupBy=popularity",
        },
    ]
    
    results = []
    for s in sources:
        results.append({"json": {
            "label": s["label"],
            "scope": s["scope"],
            "browserless_body": {
                "url": s["url"],
                "waitFor": 8000,
            },
        }})
    
    return results

    This code here actually fetches the HTML data from raider.io:

    # n8n Code Node: Fetch HTML
    # Calls browserless to render the raider.io page.
    import requests
    
    BROWSERLESS_URL = "http://browserless:3000/content?token=your_secret_token"
    
    item = _items[0]["json"]
    
    try:
        resp = requests.post(
            BROWSERLESS_URL,
            json=item["browserless_body"],
            timeout=(5, 20),
        )
        resp.raise_for_status()
        html = resp.text
    except Exception as e:
        return [{"json": {
            "label": item["label"],
            "scope": item["scope"],
            "error": str(e),
        }}]
    
    return [{"json": {
        "label": item["label"],
        "scope": item["scope"],
        "html": html,
    }}]
    

    The Result:

    (I just built this today, so no deltas yet)

    Production Tips (For the Responsible Adults)

    If you’re putting this on a real server:

    1. Reverse Proxy: Put n8n behind Nginx or Traefik with HTTPS. Set N8N_PROXY_HOPS=1 so n8n trusts the proxy headers.
    2. Firewall: Don’t expose Browserless (port 3000) or the DB (port 5432) to the internet. Only port 5678 (n8n) should be accessible via your proxy.
    3. Secrets: Use a .env file. Do not hardcode passwords in docker-compose.yml.
    4. Backups: The postgres_data volume holds everything. Back it up regularily.

    Troubleshooting

    “Python Code node doesn’t appear in n8n”

    • Check if N8N_RUNNERS_ENABLED=true is set on the n8n container.
    • Check logs: docker compose logs task-runners. It should say “Connected to broker”.

    “ModuleNotFoundError: No module named ‘requests’”

    • You probably didn’t set N8N_RUNNERS_EXTERNAL_ALLOW=* in the environment variables.
    • Or, you modified the Dockerfile but didn’t rebuild. Run docker compose up -d --build task-runners.

    “Task timed out after 60 seconds”

    • Web scraping is slow. Browserless takes time. Increase N8N_RUNNERS_TASK_TIMEOUT to 120 in the docker-compose file.

    Summary

    You should now have a working local n8n instance with browser API and remote python task runner. You can build all sorts of cool things, automate tasks and go touch some grass sometimes with all that free time.

    Thanks for reading xoxo, hugs and kisses. Sleep tight, love you 💕

  • Unlocking Full PS5 DualSense Features in Moonlight & Sunshine

    Unlocking Full PS5 DualSense Features in Moonlight & Sunshine

    There is nothing worse than buying premium hardware and having your software treat it like a generic accessory.

    I recently picked up a PS5 DualSense controller. It wasn’t cheap, but I bought it for a specific reason: that trackpad. I wanted to use the full capabilities of the controller, specifically for mouse input, while streaming.

    However, I ran into a wall immediately. No matter what I did, my setup kept auto-detecting the DualSense as a standard Xbox Controller. This meant no trackpad support and missing button functionality.

    I went down the rabbit hole of forums and documentation so you don’t have to. If you are running a similar stack, here is the fix that saves you the headache.

    The Setup

    Just for context, here is the hardware and software I’m running to play World of Warcraft:

    • Host: Virtual CachyOS running Sunshine
    • Client: MacBook (M4 Air) running Moonlight
    • Controller: PS5 DualSense
    • The Goal: Play WoW on the CachyOS host using the DualSense trackpad for mouse control and scrolling.

    The Problem

    Sunshine usually defaults to X360 (Xbox) emulation to ensure maximum compatibility, if not then Steam will. While great for most games, it kills the specific features that make the DualSense special. If you want the trackpad to work as a trackpad, you need the host to see the controller as a DualSense, not an Xbox gamepad.

    The Solution

    The fix came down to two specific steps: fixing a permission error on the Linux host and forcing Sunshine to recognize the correct controller type.

    Step 1: Fix the Permission Error

    First, we need to ensure the user has the right permissions to access the input devices.

    sudo nano /etc/udev/rules.d/60-sunshine.rules
    # sudo nano /usr/lib/udev/rules.d/60-sunshine.rules
    
    
    # Allows Sunshine to access /dev/uinput
    KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"
    
    # Allows Sunshine to access /dev/uhid (Added subsystem for persistence)
    KERNEL=="uhid", SUBSYSTEM=="misc", TAG+="uaccess"
    
    # Joypads (Broadened to ensure the tag hits before the name is fully registered)
    SUBSYSTEM=="hidraw", KERNEL=="hidraw*", MODE="0660", TAG+="uaccess"
    SUBSYSTEM=="input", ATTRS{name}=="Sunshine*", MODE="0660", TAG+="uaccess"
    sudo udevadm control --reload-rules && sudo udevadm trigger
    # optional reboot

    Source: https://github.com/LizardByte/Sunshine/issues/3758

    Step 2: Force PS5 Mode in Sunshine

    Next, we need to tell Sunshine to stop pretending everything is an Xbox controller.

    1. Open your Sunshine Web UI.
    2. Navigate to Configuration -> Input.
    3. [Insert your specific steps here, likely setting “Gamepad Emulation” to “DS4” or using a specific flag]

    Now restart Sunshine or do a full reboot. Test your controller, it should pop up in Steam now as well:

    Screenshot

    Happy Gaming! Hopefully, this saves you the hours of troubleshooting it took me. Now, back to Azeroth.


    Bonus:

    By the way, World of Warcraft with controller still has a long way to go, however I find that Questing, Farming and Delving are some activities one can easily do with a controller. I would not recommend Tanking, I am a main Guardian Druid and while really enticing due to “not that many buttons” tanking is too dynamic for controllers. PVP is extremely hectic, people will run through you to get behind you and you wont be able to turn that fast.

    All in all, I guess you can get used to anything, theoretically you also have potential to win the lottery or become a rockstar, but usually a regular job is a more stable income – as is mouse and keyboard for WoW. This was a weird analogy. It’s late here 🙁

  • The 2026 Guide to Linux Cloud Gaming: Proxmox Passthrough with CachyOS & Sunshine

    The 2026 Guide to Linux Cloud Gaming: Proxmox Passthrough with CachyOS & Sunshine

    How I turned my server into a headless gaming powerhouse, battled occasional freezes, and won using Arch-based performance and open-source streaming.

    Sorry for the clickbait, AI made me do it. For real though, I am gonna show you how to build your own stream machine, local “cloud” gaming monster.

    There are some big caveats here before we get started (to manage expectations):

    • Your mileage may vary, greatly! Depending on your hard and software versions you may not have any of the problems I have had, but you may also have many many more
    • As someone new to gaming on Linux the whole “run an executable through another layer ob virtualization/emulation” feels wrong, but I guess does not make that much of a performance difference in the end.

    If you guessed that this will be a huge long super duper long post, you guessed right… buckle up buddy!

    My Setup

    Hardware

    • ASUS TUF Gaming AMD Radeon RX 7900 XTX OC Edition 24GB
    • AMD Ryzen 7 7800X3D (AM5, 4.20 GHz, 8-Core)
    • 128GB of DDR5 RAM
    • Some HDMI Dummy Adapter: I got this one

    Software

    • Proxmox 9.1.4
    • Linux Kernel 6.17
    • CachyOS (It’s Arch btw)
    • Sunshine and Moonlight
    • Lutris (for running World of Warcraft.. yea I am that kind of nerd, I know.)

    Preperation

    Proxmox Host

    This guide is specifically for my Hardware so again: Mileage may vary.

    SSH into your Proxmox host as root or enter a shell in any way you like. We will change some stuff here.

    nano /etc/default/grub
    # look for "GRUB_CMDLINE_LINUX_DEFAULT" and change it to this
    GRUB_CMDLINE_LINUX_DEFAULT="quiet amd_iommu=on iommu=pt amdgpu.mes=0 video=efifb:off video=vesafb:off"
    update-grub
    # Blacklist
    echo "blacklist amdgpu" > /etc/modprobe.d/blacklist.conf
    echo "blacklist radeon" >> /etc/modprobe.d/blacklist.conf
    echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
    echo "blacklist nvidia" >> /etc/modprobe.d/blacklist.conf
    
    # VFIO Modules
    echo "vfio" > /etc/modules
    echo "vfio_iommu_type1" >> /etc/modules
    echo "vfio_pci" >> /etc/modules

    Basically this enables passthrough and forces to proxmox host to ignore the graphics card (we want this).

    # reboot proxmox host
    reboot

    Okay for some quality of life we will add a resource mapping for our GPU in Proxmox.

    Datacenter -> Resource Mappings -> Add

    Screenshot

    Choose a name, select your devices (Audio + Graphic Card)

    Screenshot

    Now you can use mapped devices, this will come in handy in our next step.

    CachyOS VM

    Name it whatever you like:

    You will need to download CachyOS from here

    Copy all the settings I have here, make sure you disabled the Pre-Enrolled keys, this will try to verify that the OS is signed and fail since most Linux distros aren’t:

    Leave all the defaults but use “SSD emulation” IF you are on an SSD (since we are building a gaming VM you should be):

    CPU needs to be set to host, I used 6 Cores, you can pick whatever (number of CPUs you actually have):

    Pick whatever memory you have and want to use here I am going with 16GB, disable “Ballooning” in the settings, this disabled dynamic memory management, simply put when you run this VM it will always have the full RAM available otherwise if it doesnt need it all it would ge re-assigned which is not a great idea for gaming where demands change:

    The rest is just standard:

    🚨NOTE: We have not added the GPU, yet. We will do this after installation.

    Installing CachyOS

    Literally just follow the instructions of the live image. It is super simple. If you get lost visit the CachyOS Wiki but literally just click through the installer.

    Then shut down the VM.

    Post Install

    You will want to setup SSH and Sunshine before adding the GPU. We will be blind until Sunshine works and SSH helps a lot.

    # enable ssh 
    sudo systemctl enable --now sshd
    
    # install and enable sunshine 
    sudo pacman -S sunshine lutris steam
    sysetmctl --user enable --now sunshine
    sudo setcap cap_sys_admin+p $(readlink -f $(which sunshine))
    echo 'KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/85-sunshine-input.rules
    echo 'KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/60-sunshine.rules
    systemctl --user restart sunshine
    # had to run all these to get it to work wayland is a bitch

    Sunshine settings that worked for me:

    # nano ~/.config/sunshine/sunshine.conf
    adapter_name = /dev/dri/renderD128 # <- leave auto detect or change to yours
    capture = kms
    encoder = vaapi # <- AMD specific
    locale = de
    output_name = 0 # <- depends on your actual dispslay 
    
    # restart after changing systemctl --user restart sunshine

    Edit the Firewall, CachyOS comes with ufw enabled by default:

    # needed for sunshine and ssh of course
    sudo ufw allow 47990/tcp
    sudo ufw allow 47984/tcp
    sudo ufw allow 47989/tcp
    sudo ufw allow 48010/tcp
    sudo ufw allow 47998/udp
    sudo ufw allow 47999/udp
    sudo ufw allow 48000/udp
    sudo ufw allow 48002/udp
    sudo ufw allow 48010/udp
    sudo ufw allow ssh

    Before we turn off the VM we need to enable automatic sign in and set the energy saving to never. We have to do this because Sunshine runs as user and if the user is not logged in then it does not have a display to show, if the energy saver shuts down the “Display” Sunshine wont work either.

    As a security person I really don’t like an OS without proper sign in. Password is still needed for sudo, but for the sign in none is needed. I recommend tightening your Firewall or using Tailscale or Wireguard to allow only authenticated clients to connect.

    Now you will turn off the VM and remove the virtual display:

    Screenshot

    You need to download the Moonlight Client from here, they have a client for pretty much every single device on earth. The client will probably find your Sunshine server as is but if not you can just add the client manually (like I had to do).

    This step is so easy that I didn’t think I needed to add any more info here.

    Bringing it all together

    Okay, now add the GPU to the VM, double check that it is turned off.

    Select the VM -> Hardware -> Add -> PCI Device

    Select your mapped GPU, ensure Primary GPU is selected, select the ROM-Bar (Important! This will help with the GPU getting stuck on reboot and shutdown, yes that is a thing). Tick on PCI-Express:

    It should look something like this:

    Now insert the HDMI Dummy Plug into the GPU and start the VM

    You should now be able to SSH into your VM:

    Screenshot

    Testing

    If you are lucky then everything works out of the box now. I am not lucky.

    I couldn’t get games to start through Steam thy kept crashing, the issue seemed to be old / non-existent Vulkan drivers for the GPU.

    sudo pacman -Syu mesa lib32-mesa vulkan-radeon lib32-vulkan-radeon lib32-vulkan-mesa-layers lib32-libdisplay-info
    sudo pacman -Syu

    That fixed my Vulkan errors:

    ~ karl@cachyos-x8664
     vulkaninfo --summary
    .....
    Devices:
    ========
    GPU0:
            apiVersion         = 1.4.328
            driverVersion      = 25.3.4
            vendorID           = 0x1002
            deviceID           = 0x744c
            deviceType         = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU
            deviceName         = AMD Radeon RX 7900 XTX (RADV NAVI31)
            driverID           = DRIVER_ID_MESA_RADV
            driverName         = radv
            driverInfo         = Mesa 25.3.4-arch1.2
            conformanceVersion = 1.4.0.0
    ....

    Here you can see Witcher 3 running:

    Installing Battle.net

    You can follow this guide here for the installation of Lutris. I just did:

    sudo pacman -S lutris

    Maybe that is why I have had issues? Who knows, it works now.

    The rest is really simple:

    • Start Lutris
    • Add new game
    • Search for “battlenet”
    • Install (follow the instructions, this is important)

    Once installed you need to add Battle.net App into Steam as a

    Screenshot

    Once you pressed play you can log in to your Battle.net Account and start:

    Screenshot
    • Resolution: 4K (3840×2160)
    • Framerate: Solid 60 FPS
    • Latency: ~5.6ms Host Processing (Insanely fast!)
    • Codec: HEVC (Hardware Encoding working perfectly)

    Wrapping Up: The 48-Hour Debugging Marathon

    I’m not going to lie to you, this wasn’t a quick “plug-and-play” tutorial. It took me a solid two days of tinkering, debugging, and staring at terminal logs to get this setup from “broken mess” to a high-performance cloud gaming beast.

    We battled through Proxmox hooks, fought against dependency hell, and wrestled with Vulkan drivers until everything finally clicked.

    I honestly hope this post acts as the shortcut I wish I had. If this guide saves you even just an hour of the headaches I went through, then every second of my troubleshooting was worth it.

    And if you’re still stuck? Just know that we have suffered together, and you are not alone in the Linux trenches! 😂

    For my next experiment, I think I’m going to give Bazzite a spin. I’ve heard great things about its “out-of-the-box” simplicity and stability. But let’s be real for a second: Bazzite isn’t Arch-based. If I switch, I lose the sacred ability to drop “I use Arch, btw” into casual conversation, and I’m not sure I’m emotionally ready to give up those bragging rights just yet.

    Anyway, thank you so much for sticking with me to the end of this guide. You made it!

    Love you, cutiepie! ❤️ Byyyeeeeeeeee!

  • Why I Cancelled ChatGPT Plus: Saving €15/Month with Gemini Advanced

    Why I Cancelled ChatGPT Plus: Saving €15/Month with Gemini Advanced

    The Switch: Why I Dumped ChatGPT Plus for Gemini

    For a long time, I was a loyal subscriber to ChatGPT Plus. I happily paid the €23.99/month to access the best AI models. But recently, my focus shifted. I’m currently optimizing my finances to invest more in index ETFs and aim for early retirement (FIRE). Every Euro counts.

    That’s when I stumbled upon a massive opportunity: Gemini Advanced.

    I managed to snag a promotional deal for Gemini Advanced at just €8.99/month. That is nearly 65% cheaper than ChatGPT Plus for a comparable, and in some ways superior, feature set. Multimodal capabilities, huge context windows, and deep Google integration for the price of a sandwich? That is an immediate win for my portfolio.

    (Not using AI obviously is not an option anymore in 2026, sorry not sorry)

    The Developer Nightmare: Scraping ChatGPT

    As a developer, I love automating tasks. With ChatGPT, I built my own “API” to bypass the expensive official token costs. I wrote a script to automate the web interface, but it was a maintenance nightmare.

    The ChatGPT website and app seemed to change weekly. Every time they tweaked a div class or a button ID, my script broke. I spent more time fixing my “money-saving” tool than actually using it. It was painful, annoying, and unreliable.

    The Python Upgrade: Unlocking Gemini

    When I switched to Gemini, I looked for a similar solution and found an open-source gem: Gemini-API by HanaokaYuzu.

    This developer has built an incredible, stable Python wrapper for the Gemini Web interface. It pairs perfectly with my new subscription, allowing me to interact with Gemini Advanced programmatically through Python.

    I am now paying significantly less money for a cutting-edge AI model that integrates seamlessly into my Python workflows. If you are looking to cut subscriptions without cutting capabilities, it’s time to look at Gemini.

    The Setup Guide

    How to Set Up Your Python Wrapper

    If you want to use the HanaokaYuzu wrapper to mimic the web interface, you will need to grab your session cookies. This effectively “logs in” the script as you.

    ⚠️ Important Note: This method relies on your browser cookies. If you log out of Google or if the cookies expire, you will need to repeat these steps. For a permanent solution, use the official Gemini API and Google Cloud.

    Step 1: Get Your Credentials 

    You don’t need a complex API key for this wrapper; you just need to prove you are a human. Here is how to find your __Secure-1PSID and __Secure-1PSIDTS tokens: Copy the long string of characters from the Value column for both.

    • Open your browser (Chrome, Firefox, or Edge) and navigate to gemini.google.com.
    • Ensure you are logged into the Google account you want to use.
    • Open the Developer Tools:
      • Windows/Linux: Press F12 or Ctrl + Shift + I.
      • Mac: Press Cmd + Option + I.
    • Navigate to the Application tab (in Chrome/Edge) or the Storage tab (in Firefox).
    • Ensure you are logged into the Google account you want to use.

    On the left sidebar, expand the Cookies dropdown and select https://gemini.google.com.

    Look for the following two rows in the list:

    • __Secure-1PSID
    • __Secure-1PSIDTS

    Step 2: Save the Cookies

    Add a .env to your coding workspace:

    # Gemini API cookies
    SECURE_1PSID=g.a00
    SECURE_1PSIDTS=sidts-CjE

    Examples

    Automating Image Generation

    We have our cookies, we have our wrapper, and now we are going to build with Nano Banana. This script will hit the Gemini API, request a specific image, and save it locally, all without opening a browser tab.

    Here is the optimized, async-ready Python script:

    import asyncio
    import os
    import sys
    from pathlib import Path
    
    # Third-party imports
    from dotenv import load_dotenv
    from gemini_webapi import GeminiClient, set_log_level
    from gemini_webapi.constants import Model
    
    # Load environment variables
    load_dotenv()
    Secure_1PSID = os.getenv("SECURE_1PSID")
    Secure_1PSIDTS = os.getenv("SECURE_1PSIDTS")
    
    # Enable logging for debugging
    set_log_level("INFO")
    
    def get_client():
        """Initialize the client with our cookies."""
        return GeminiClient(Secure_1PSID, Secure_1PSIDTS, proxy=None)
    
    async def gen_and_edit():
        # Setup paths
        temp_dir = Path("temp")
        temp_dir.mkdir(exist_ok=True)
        
        # Import our local watermark remover (see next section)
        # We add '.' to sys.path to ensure Python finds the file
        sys.path.append('.')
        try:
            from watermark_remover import remove_watermark
        except ImportError:
            print("Warning: Watermark remover module not found. Skipping cleanup.")
            remove_watermark = None
    
        client = get_client()
        await client.init()
    
        prompt = "Generate a photorealistic picture of a ragdoll cat dressed as a baker inside of a bakery shop"
        print(f"🎨 Sending prompt: {prompt}")
    
        response = await client.generate_content(prompt)
    
        for i, image in enumerate(response.images):
            filename = f"cat_{i}.png"
            img_path = temp_dir / filename
            
            # Save the raw image from Gemini
            await image.save(path="temp/", filename=filename, verbose=True)
            
            # If we have the remover script, clean the image immediately
            if remove_watermark:
                print(f"✨ Polishing image: {img_path}")
                cleaned = remove_watermark(img_path)
                cleaned.save(img_path)
                print(f"✅ Done! Saved to: {img_path}")
    
    if __name__ == "__main__":
        asyncio.run(gen_and_edit())

    If you have ever tried running a high-quality image generator (like Flux or SDXL) on your own laptop, you know the pain. You need massive amounts of VRAM, a beefy GPU, and patience. Using Gemini offloads that heavy lifting to Google’s supercomputers, saving your hardware.

    But there is a “tax” for this free cloud compute: The Watermark.

    Gemini stamps a semi-transparent logo on the bottom right of every image. While Google also uses SynthID (an invisible watermark for AI detection), the visible logo ruins the aesthetic for professional use.

    The Fix: Mathematical Cleaning

    You might think you need another AI to “paint over” the watermark, but that is overkill. Since the watermark is always the same logo applied with the same transparency, we can use Reverse Alpha Blending.

    I found an excellent Python implementation by journey-ad (ported to Python here) that subtracts the known watermark values from the pixels to reveal the original colors underneath.

    ⚠️ Important Requirement: To run the script below, you must download the alpha map files (bg_48.png and bg_96.png) from the original repository and place them in the same folder as your script.

    Here is the cleaning module:

    #!/usr/bin/env python3
    """
    Gemini Watermark Remover - Python Implementation
    Ported from journey-ad/gemini-watermark-remover
    """
    import sys
    from pathlib import Path
    from PIL import Image
    import numpy as np
    from io import BytesIO
    
    # Ensure bg_48.png and bg_96.png are in this folder!
    ASSETS_DIR = Path(__file__).parent
    
    def load_alpha_map(size):
        """Load and calculate alpha map from the background assets."""
        bg_path = ASSETS_DIR / f"bg_{size}.png"
        if not bg_path.exists():
            raise FileNotFoundError(f"Missing asset: {bg_path} - Please download from repo.")
        
        bg_img = Image.open(bg_path).convert('RGB')
        bg_array = np.array(bg_img, dtype=np.float32)
        # Normalize to [0, 1]
        return np.max(bg_array, axis=2) / 255.0
    
    # Cache the maps so we don't reload them every time
    _ALPHA_MAPS = {}
    
    def get_alpha_map(size):
        if size not in _ALPHA_MAPS:
            _ALPHA_MAPS[size] = load_alpha_map(size)
        return _ALPHA_MAPS[size]
    
    def detect_watermark_config(width, height):
        """
        Gemini uses a 96px logo for images > 1024px, 
        and a 48px logo for everything else.
        """
        if width > 1024 and height > 1024:
            return {"logo_size": 96, "margin": 64}
        else:
            return {"logo_size": 48, "margin": 32}
    
    def remove_watermark(image, verbose=False):
        """
        The Magic: Reverses the blending formula:
        original = (watermarked - alpha * logo) / (1 - alpha)
        """
        # Load image and convert to RGB
        if isinstance(image, (str, Path)):
            img = Image.open(image).convert('RGB')
        elif isinstance(image, bytes):
            img = Image.open(BytesIO(image)).convert('RGB')
        else:
            img = image.convert('RGB')
    
        width, height = img.size
        config = detect_watermark_config(width, height)
        logo_size = config["logo_size"]
        margin = config["margin"]
        
        # Calculate position (Bottom Right)
        x = width - margin - logo_size
        y = height - margin - logo_size
    
        if x < 0 or y < 0:
            return img # Image too small
    
        # Get the math ready
        alpha_map = get_alpha_map(logo_size)
        img_array = np.array(img, dtype=np.float32)
        
        LOGO_VALUE = 255.0  # The watermark is white
        MAX_ALPHA = 0.99    # Prevent division by zero
    
        # Process only the watermark area
        for row in range(logo_size):
            for col in range(logo_size):
                alpha = alpha_map[row, col]
                
                # Skip noise
                if alpha < 0.002: continue
                
                alpha = min(alpha, MAX_ALPHA)
                
                # Apply the reverse blend to R, G, B channels
                for c in range(3):
                    pixel_val = img_array[y + row, x + col, c]
                    restored = (pixel_val - alpha * LOGO_VALUE) / (1.0 - alpha)
                    img_array[y + row, x + col, c] = max(0, min(255, round(restored)))
    
        return Image.fromarray(img_array.astype(np.uint8), 'RGB')
    
    # Main block for CLI usage
    if __name__ == "__main__":
        if len(sys.argv) < 2:
            print("Usage: python remover.py <image_path>")
            sys.exit(1)
        
        img_path = Path(sys.argv[1])
        result = remove_watermark(img_path, verbose=True)
        output = img_path.parent / f"{img_path.stem}_clean{img_path.suffix}"
        result.save(output)
        print(f"Saved cleaned image to: {output}")

    You could now build som etching with FastAPI on top of this and have your own image API! Yay.

    The “LinkedIn Auto-Pilot” (With Memory)

    ⚠️ The Danger Zone (Read This First)

    Before we look at the code, we need to address the elephant in the room. What we are doing here is technically against the Terms of Service.

    When you use a wrapper to automate your personal Google account:

    1. Session Conflicts: You cannot easily use the Gemini web interface and this Python script simultaneously. They fight for the session state.
    2. Chat History: This script will flood your Gemini sidebar with hundreds of “New Chat” entries.
    3. Risk: There is always a non-zero risk of Google flagging the account. Do not use your primary Google account for this.

    Now that we are all adults here… let’s build something cool.

    The Architecture: Why “Human-in-the-Loop” Matters

    I’ve tried fully automating social media before. It always ends badly. AI hallucinates, it gets the tone wrong, or it sounds like a robot.

    That is why I built a Staging Environment. My script doesn’t post to LinkedIn. It posts to Flatnotes (my self-hosted note-taking app).

    The Workflow:

    1. Python Script wakes up.
    2. Loads Memory: Checks memory.json to see what we talked about last week (so we don’t repeat topics).
    3. Generates Content: Uses a heavy-duty system prompt to create a viral post.
    4. Staging: Pushes the draft to Flatnotes via API.
    5. Human Review: I wake up, read the note, tweak one sentence, and hit “Post.”

    The Code: The “Viral Generator”

    This script uses asyncio to handle the network requests and maintains a local JSON database of past topics.

    Key Features:

    • JSON Enforcement: It forces Gemini to output structured data, making it easy to parse.
    • Topic Avoidance: It reads previous entries to ensure fresh content.
    • Psychological Prompting: The prompt explicitly asks for “Fear & Gap” and “Thumb-Stoppers” marketing psychology baked into the code.
    from random import randint
    import time
    import aiohttp
    import datetime
    import json
    import os
    import asyncio
    from gemini_webapi import GeminiClient, set_log_level
    from dotenv import load_dotenv
    
    load_dotenv()
    
    # Set log level for debugging
    set_log_level("INFO")
    
    MEMORY_PATH = os.path.join(os.path.dirname(__file__), "memory.json")
    HISTORY_PATH = os.path.join(os.path.dirname(__file__), "history.json")
    FLATNOTES_API_URL = "https://flatnotes.notarealdomain.de/api/notes/LinkedIn"
    FLATNOTES_USERNAME = os.getenv("FLATNOTES_USERNAME")
    FLATNOTES_PASSWORD = os.getenv("FLATNOTES_PASSWORD")
    Secure_1PSID = os.getenv("SECURE_1PSID")
    Secure_1PSIDTS = os.getenv("SECURE_1PSIDTS")
    
    
    async def post_to_flatnotes(new_post):
        """
        Fetches the current note, prepends the new post, and updates the note using Flatnotes API with basic auth.
        """
        if not FLATNOTES_USERNAME or not FLATNOTES_PASSWORD:
            print(
                "[ERROR] FLATNOTES_USERNAME or FLATNOTES_PASSWORD is not set in .env. Skipping Flatnotes update."
            )
            return
        token_url = "https://notes.karlcloud.de/api/token"
        async with aiohttp.ClientSession() as session:
        
            # 1. Get bearer token
            token_payload = {"username": FLATNOTES_USERNAME, "password": FLATNOTES_PASSWORD}
            async with session.post(token_url, json=token_payload) as token_resp:
                if token_resp.status != 200:
                    print(f"[ERROR] Failed to get token: {token_resp.status}")
                    return
                token_data = await token_resp.json()
                access_token = token_data.get("access_token")
                if not access_token:
                    print("[ERROR] No access_token in token response.")
                    return
            headers = {"Authorization": f"Bearer {access_token}"}
            
            # 2. Get current note content
            async with session.get(FLATNOTES_API_URL, headers=headers) as resp:
                if resp.status == 200:
                    try:
                        data = await resp.json()
                        current_content = data.get("content", "")
                    except aiohttp.ContentTypeError:
                        # Fallback: treat as plain text
                        current_content = await resp.text()
                else:
                    current_content = ""
                    
            # Prepend new post
            updated_content = f"{new_post}\n\n---\n\n" + current_content
            patch_payload = {"newContent": updated_content}
            async with session.patch(
                FLATNOTES_API_URL, json=patch_payload, headers=headers
            ) as resp:
                if resp.status not in (200, 204):
                    print(f"[ERROR] Failed to update Flatnotes: {resp.status}")
                else:
                    print("[INFO] Flatnotes updated successfully.")
    
    
    def save_history(new_json):
        arr = []
        if os.path.exists(HISTORY_PATH):
            try:
                with open(HISTORY_PATH, "r", encoding="utf-8") as f:
                    arr = json.load(f)
                    if not isinstance(arr, list):
                        arr = []
            except Exception:
                arr = []
        arr.append(new_json)
        with open(HISTORY_PATH, "w", encoding="utf-8") as f:
            json.dump(arr, f, ensure_ascii=False, indent=2)
        return arr
    
    
    def load_memory():
        if not os.path.exists(MEMORY_PATH):
            return []
        try:
            with open(MEMORY_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
                if isinstance(data, list):
                    return data
                return []
        except Exception:
            return []
    
    
    def save_memory(new_json):
        arr = load_memory()
        arr.append(new_json)
        arr = arr[-3:]  # Keep only last 3
        with open(MEMORY_PATH, "w", encoding="utf-8") as f:
            json.dump(arr, f, ensure_ascii=False, indent=2)
        return arr
    
    
    def get_client():
        return GeminiClient(Secure_1PSID, Secure_1PSIDTS, proxy=None)
    
    
    def get_current_date():
        return datetime.datetime.now().strftime("%d. %B %Y")
    
    
    async def example_generate_content():
        client = get_client()
        await client.init()
        chat = client.start_chat(model="gemini-3.0-pro")
    
        memory_entries = load_memory()
        memory_str = ""
        if memory_entries:
            memory_str = "\n\n---\nVergangene LinkedIn-Posts (letzte 3):\n" + "\n".join(
                [
                    json.dumps(entry, ensure_ascii=False, indent=2)
                    for entry in memory_entries
                ]
            )
    
        prompt = (
            """
            **Role:** Du bist ein weltklasse LinkedIn-Strategist (Top 1% Creator) und Verhaltenspsychologe.
            **Mission:** Erstelle einen viralen LinkedIn-Post, kurz knapp auf den punkt, denn leute lesen nur wenig und kurz, der mich als die unangefochtene Autorität für Cybersecurity & AI Governance in der DACH-Region etabliert.
            **Ziel:** Maximale Reichweite (100k Follower Strategie) + direkte Lead-Generierung für "https://karlcom.de" (High-Ticket Consulting).
            **Output Format:** Ausschließlich valides JSON.
            **Datum:** Heute ist der """
            + str(get_current_date())
            + """ nutze nur brand aktuelle Themen.
    
            **PHASE 1: Deep Intelligence (Google Search)**
            Nutze Google Search. Suche nach "Trending News Cybersecurity AI Cloud EU Sovereignty last 24h".
            Finde den "Elephant in the room" – das Thema, das C-Level Manager (CISO, CTO, CEO) gerade nachts wach hält, über das aber noch keiner Tacheles redet.
            * *Fokus:* Große Schwachstellen, Hackerangriffe, Datenleaks, AI, Cybersecurity, NIS2-Versäumnisse, Shadow-AI Datenlecks, Cloud-Exit-Szenarien.
            * *Anforderung:* Es muss ein Thema mit finanziellem oder strafrechtlichem Risiko sein.
    
            **PHASE 2: Die "Viral Architecture" (Konstruktion)**
            Schreibe den Post auf DEUTSCH. Befolge strikt diese 5-Stufen-Matrix für Viralität:
    
            **1. The "Thumb-Stopper" (Der Hook - Zeile 1-2):**
            * Keine Fragen ("Wussten Sie...?").
            * Keine Nachrichten ("Heute wurde Gesetz X verabschiedet").
            * **SONDERN:** Ein harter Kontrarian-Standpunkt oder eine unbequeme Wahrheit.
            * *Stil:* "Ihr aktueller Sicherheitsplan ist nicht nur falsch. Er ist fahrlässig."
            * *Ziel:* Der Leser spürt einen körperlichen Impuls, weiterzulesen.
    
            **2. The "Fear & Gap" (Die Agitation):**
            * Erkläre die Konsequenz der News aus Phase 1.
            * Nutze "Loss Aversion": Zeige auf, was sie verlieren (Geld, Reputation, Job), wenn sie das ignorieren.
            * Nutze kurze, rhythmische Sätze (Staccato-Stil). Das erhöht die Lesegeschwindigkeit massiv.
    
            **3. The "Authority Bridge" (Die Wende):**
            * Wechsle von Panik zu Kompetenz.
            * Zeige auf, dass blinder Aktionismus jetzt falsch ist. Man braucht Strategie.
            * Hier etablierst du deinen Status: Du bist der Fels in der Brandung.
    
            **4. The "Soft Pitch" (Die Lösung):**
            * Biete **Karlcom.de** als exklusive Lösung an. Nicht betteln ("Wir bieten an..."), sondern feststellen:
            * *Wording:* "Das ist der Standard, den wir bei Karlcom.de implementieren." oder "Deshalb rufen uns Vorstände an, wenn es brennt."
    
            **5. The "Engagement Trap" (Der Schluss):**
            * Stelle eine Frage, die man nicht mit "Ja/Nein" beantworten kann, sondern die eine Meinung provoziert. (Treibt den Algorithmus).
            * Beende mit einem imperativen CTA wie zum Beispiel: "Sichern wir Ihre Assets."
    
            **PHASE 3: Anti-AI & Status Checks**
            * **Verbotene Wörter (Sofortiges Disqualifikations-Kriterium):** "entfesseln", "tauchen wir ein", "nahtlos", "Gamechanger", "In der heutigen Welt", "Synergie", "Leuchtturm".
            * **Verbotene Formatierung:** Keine **fetten** Sätze (wirkt werblich). Keine Hashtag-Blöcke > 3 Tags.
            * **Emojis:** Maximal 2. Nur "Status-Emojis" (📉, 🛑, 🔒, ⚠️). KEINE Raketen 🚀.
    
            **PHASE 4: JSON Output**
            Erstelle das JSON. Der `post` String muss `\n` für Zeilenumbrüche nutzen.
    
            **Output Schema:**
            ```json
            {
            "analyse": "Kurze Erklärung, warum dieses Thema heute viral gehen wird (Psychologischer Hintergrund).",
            "thema": "Titel des Themas",
            "source": "Quelle",
            "post": "Zeile 1 (Thumb-Stopper)\n\nZeile 2 (Gap)\n\nAbsatz (Agitation)...\n\n(Authority Bridge)...\n\n(Pitch Karlcom.de)...\n\n(Engagement Trap)"
            }
    
            **Context für vergangene Posts, diese Themen solltest du erstmal vermeiden:**\n\n"""
            + memory_str
        )
    
        response = await chat.send_message(prompt.strip())
        previous_session = chat.metadata
    
        max_attempts = 3
        newest_post_str = None
        def format_flatnotes_post(json_obj):
            heading = f"# {json_obj.get('thema', '').strip()}\n"
            analyse = json_obj.get('analyse', '').strip()
            analyse_block = f"\n```psychology\n{analyse}\n```\n" if analyse else ""
            post = json_obj.get('post', '').strip()
            source = json_obj.get('source', '').strip()
            source_block = f"\nQuelle: {source}" if source else ""
            return f"{heading}{analyse_block}\n{post}{source_block}"
        for attempt in range(max_attempts):
            try:
                text = response.text.strip()
                if text.startswith("```json"):
                    text = text[7:].lstrip()
                if text.endswith("```"):
                    text = text[:-3].rstrip()
                json_obj = json.loads(text)
                save_memory(json_obj)
                save_history(json_obj)
                newest_post_str = format_flatnotes_post(json_obj)
                break
            except Exception:
                print(response.text)
                print("- output was not valid json, retrying...")
                if attempt < max_attempts - 1:
                    previous_chat = client.start_chat(metadata=previous_session)
                    response = await previous_chat.send_message(
                        f"ENSURE PROPER JSON OUTPUT!\n\n{prompt}"
                    )
                else:
                    print("[ERROR] Failed to get valid JSON response after 3 attempts.")
    
        # Post to Flatnotes if we have a valid post
        if newest_post_str:
            await post_to_flatnotes(newest_post_str)
    
    
    async def main():
        await example_generate_content()
    
    
    if __name__ == "__main__":
        for i in range(50):
            asyncio.run(main())
            time.sleep(randint(60, 300))  # Wait between 1 to 5 minutes before next run
    

    The Result (Case Study)

    Real-World Example: The “Ethics & Liability” Angle

    To prove this isn’t just generating generic corporate fluff, let’s look at a raw output from a simulation run.

    I set the internal date to January 31, 2026 (a future scenario regarding EU regulations) and asked the AI to find the “Elephant in the room” regarding Cybersecurity.

    The AI didn’t choose a technical topic like “Firewalls.” It chose Personal Liability. It identified that C-Level executives are terrified of going to jail or losing personal assets due to negligence.

    Here is the raw JSON output from the script. Pay attention to the analyse field, this is the AI “thinking” before it writes.

    {
      "analyse": "Psychologischer Hebel: 'Personal Accountability' & 'Optimism Bias'. C-Level-Führungskräfte leben oft in der Illusion, Cyber-Risiken durch die Einstellung eines CISO vollständig an diesen delegiert zu haben (Moral Hazard). Der 'Elephant in the room' am 31.01.2026 ist das Ende der Schonfrist für die NIS2-Umsetzung...",
      "thema": "Der CISO-Irrtum: Warum Delegation Sie jetzt ins Gefängnis bringt",
      "source": "EU NIS2 Enforcement Reports Q1 2026 / Handelsblatt",
      "post": "Ihr CISO ist nicht Ihre Versicherung.\nEr ist Ihr potenzieller Belastungszeuge.\n\nDer Irrglaube vieler Vorstände hat im Januar 2026 ein jähes Ende gefunden.\n\nDie erste Welle der NIS2-Prüfungen rollt durch die DACH-Region.\nUnd die Aufsichtsbehörden suchen nicht nach technischen Fehlern.\nSie suchen nach 'Organisationsverschulden'.\n\nDas Szenario:\nEin Ransomware-Angriff legt die Produktion lahm.\nDer CEO sagt: 'Ich habe doch einen Top-CISO eingestellt. Das war sein Job.'\n\nDer Richter sagt: 'Nein. Die Überwachungspflicht ist nicht delegierbar.'\n\nWenn Sie Cyber-Resilienz als reine IT-Aufgabe behandeln, handeln Sie grob fahrlässig.\nDer CISO liefert die Technik.\nAber *Sie* liefern die Governance.\n\nWenn Sie das Risikoprofil Ihrer eigenen Supply Chain nicht ohne PowerPoint erklären können, haften Sie.\nNicht die GmbH.\nSie persönlich.\nMit Ihrem Privatvermögen.\n\n[...]\n\nSichern wir Ihre Assets. ⚖️"
    }
    

    Why This Post Works (The Anatomy)

    This output demonstrates exactly why we use the “Human-in-the-Loop” architecture with Flatnotes. The AI followed the 5-step viral matrix perfectly:

    1. The Hook:“Ihr CISO ist nicht Ihre Versicherung. Er ist Ihr potenzieller Belastungszeuge.”
      • It attacks a common belief immediately. It’s controversial and scary.
    2. The Agitation: It creates a specific scenario (Courtroom, Judge vs. CEO). It uses the psychological trigger of Loss Aversion (“Mit Ihrem Privatvermögen” / “With your private assets”).
    3. The Authority Bridge: It stops the panic by introducing a clear concept: “Executive-Shield Standard.”
    4. The Tone: It avoids typical AI words like “Synergy” or “Landscape.” It is short, punchy, and uses a staccato rhythm.

    Summary

    By combining Gemini’s 2M Context Window (to read news) with Python Automation (to handle the logic) and Flatnotes (for human review), we have built a content engine that doesn’t just “write posts”—it thinks strategically.

    It costs me pennies in electricity, saves me hours of brainstorming, and produces content that is arguably better than 90% of the generic posts on LinkedIn today.

    The Verdict

    From Consumer to Commander

    We started this journey with a simple goal: Save €15 a month by cancelling ChatGPT Plus. But we ended up with something much more valuable.

    By switching to Gemini Advanced and wrapping it in Python, we moved from being passive consumers of AI to active commanders.

    • We built a Nano Banana Image Generator that bypasses the browser and cleans up its own mess (watermarks).
    • We engineered a LinkedIn Strategist that remembers our past posts, researches the news, and writes with psychological depth, all while we sleep.

    Is This Setup for You?

    This workflow is not for everyone. It is “hacky.” It relies on browser cookies that expire. It dances on the edge of Terms of Service.

    • Stick to ChatGPT Plus if: …can’t think of a reason, it is sub-par in every way
    • Switch to Gemini & Python if: You are a builder. You want to save money and you want to build custom workflows that no off-the-shelf product can offer (for free 😉).

    The Final Word on “Human-in-the-Loop”

    The most important lesson from our LinkedIn experiment wasn’t the code, it was the workflow. The AI generates the draft, but the Human (you) makes the decision.

    Whether you are removing watermarks from a cat picture or approving a post about Cyber-Liability, the magic happens when you use AI to do the heavy lifting, leaving you free to do the creative directing.

    Ready to build your own agent? 

    Happy coding! 🚀

  • Forget Google: Build Your Own Search API with SearXNG

    Forget Google: Build Your Own Search API with SearXNG

    Ever ask your mom for a shiny new Google, only to hear:

    We have Google at home, son!

    and then she proudly shows you her self-hosted SearXNG instance?

    Yeah… me neither.

    But today, let me play that role for you and introduce you to my very own SearXNG setup.

    What is SearXNG ?

    In other words (ChatGPTs):

    SearxNG is a privacy-friendly meta-search engine. Instead of being one search engine like Google, it asks lots of engines at once (Google, Bing, Wikipedia, etc.) and shows you all the results together, without ads, tracking, or profiling.

    Think of it like calling ten friends for advice instead of one, but none of them know who you are. 🤫 (kind of like you and I, fren ❤️)

    Despite the intro, you don’t have to self-host SearXNG, a lot of people host an instance for you you can use, there is a directory here: https://searx.space

    Screenshot

    Self-Hosting SearXNG

    Of course we’re hosting it ourselves, trusting someone else with your searches? Ha! Not today.

    I run a Proxmox server at home (something I’ve rambled about in other posts). For my current SearXNG instance, I pretty much just used this script:

    👉 Proxmox Community SearXNG Script

    The Proxmox Community Scripts page is a gem, it makes spinning up your own VMs or containers as simple as a single bash command. The catch is that you are running random scripts from the internet on your system…ewww. Reviewing them is usually so annoying that if you’re truly paranoid, you might as well build it yourself.

    Sure, you could go the Docker route, but then you’ve got to audit the Dockerfile too. Pick your poison. Personally, I stick with Proxmox Community Scripts, but I also keep a close eye with Wazuh, honeypots, and Grafana+Loki. Any network call I didn’t make or plan,I hear about it immediately.

    Docker Option

    If you prefer Docker, SearXNG has an official repo with a handy docker-compose file:

    👉 searxng/searxng-docker

    At the time of writing, the compose file looks like this:

    services:
      caddy:
        container_name: caddy
        image: docker.io/library/caddy:2-alpine
        network_mode: host
        restart: unless-stopped
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - caddy-data:/data:rw
          - caddy-config:/config:rw
        environment:
          - SEARXNG_HOSTNAME=${SEARXNG_HOSTNAME:-http://localhost}
          - SEARXNG_TLS=${LETSENCRYPT_EMAIL:-internal}
        logging:
          driver: "json-file"
          options:
            max-size: "1m"
            max-file: "1"
    
      redis:
        container_name: redis
        image: docker.io/valkey/valkey:8-alpine
        command: valkey-server --save 30 1 --loglevel warning
        restart: unless-stopped
        networks:
          - searxng
        volumes:
          - valkey-data2:/data
        logging:
          driver: "json-file"
          options:
            max-size: "1m"
            max-file: "1"
    
      searxng:
        container_name: searxng
        image: docker.io/searxng/searxng:latest
        restart: unless-stopped
        networks:
          - searxng
        ports:
          - "127.0.0.1:8080:8080"
        volumes:
          - ./searxng:/etc/searxng:rw
          - searxng-data:/var/cache/searxng:rw
        environment:
          - SEARXNG_BASE_URL=https://${SEARXNG_HOSTNAME:-localhost}/
        logging:
          driver: "json-file"
          options:
            max-size: "1m"
            max-file: "1"
    
    networks:
      searxng:
    
    volumes:
      caddy-data:
      caddy-config:
      valkey-data2:
      searxng-data:

    Honestly, I wish I had some epic war stories about running SearXNG… but it’s almost disappointingly easy 😂. I just left the standard settings as they are, no tweaks, no drama.

    SearXNG API

    Now here’s the fun part: the API.

    In my opinion, the sexiest feature of SearXNG is its built-in search API. Normally, you’d have to pay through the nose for this kind of functionality to power your OSINT workflows, AI tools, or random scripts. With SearXNG, you get it for free. (Okay, technically the search engines themselves apply rate limits, but still, that’s a sweet deal.)

    Enabling it is dead simple. Just flip the switch in your config:

    nano /etc/searxng/settings.yml

    Add:

    search:
      safe_search: 2
      autocomplete: 'google'
      formats:
        - html
        - json # <- THIS!

    Boom 💥 you’ve got yourself a free, self-hosted search API you can use like so:

    https://your-search.instance/search?q=karl.fail&format=json

    {"query": "karl.fail", "number_of_results": 0, "results": [{"url": "https://karl.fail/", "title": "Home - Karl.Fail", "content": "Karl.Fail \u00b7 Home \u00b7 Blog \u00b7 Projects \u00b7 Tools \u00b7 Vulnerabilities \u00b7 Disclaimer. Hey,. I'm ... Thanks for stopping by, and enjoy exploring! GitHub \u00b7 LinkedIn \u00b7 Karlcom\u00a0...", "publishedDate": null, "thumbnail": "", "engine": "brave", "template": "default.html", "parsed_url": ["https", "karl.fail", "/", "", "", ""], "img_src": "", "priority": "", "engines": ["brave", "startpage", "duckduckgo"], "positions": [1, 1, 1], "score": 9.0, "category": "general"}, {"url": "https://en.wikipedia.org/wiki/Carl_Fail", "title": "Carl Fail - Wikipedia", "content": "Carl Fail (born 16 January 1997) is an English professional boxer. As an amateur he won the 2016 England Boxing welterweight championship and a silver medal in the middleweight division at the 2018 European Union Championships. In July 2020, Fail turned professional along with his twin brother Ben.", "publishedDate": "2025-07-27T00:00:00", "thumbnail": "", "engine": "brave", "template": "default.html", "parsed_url": ["https", "en.wikipedia.org", "/wiki/Carl_Fail", "", "", ""], "img_src": "", "priority": "", "engines": ["brave", "startpage"],
    .......

    When you query the API, you’ll get a nice clean JSON response back. (I trimmed this one down so you don’t have to scroll forever.)

    Node-RED + SearXNG

    And this is where things get fun(ner). Instead of just running curl commands, you can wire up SearXNG directly into Node-RED. That means you can chain searches into automations, OSINT pipelines, or even goofy side projects, without touching a line of code (except copy and pasting mine, you sly dog).

    There are countless ways to use SearXNG, either as your daily driver for private search, or as a clean JSON API powering your tools, OSINT automations, and little gremlins you call “scripts.”

    Let me show you a quick Node-RED function node:

    const base = "https://your.domain/search"; 
    
    const qs = "q=" + encodeURIComponent(msg.payload )
        + "&format=json"
        + "&pageno=1";
        
    msg.method = "GET";
    msg.url = base + "?" + qs;
    msg.headers = { "Accept": "application/json" };
    
    return msg;

    msg.payload = your search term. Everything else just wires the pieces together:

    Flow:

    Inject → Function → HTTP Request → JSON → Debug

    When you run the flow, you’ll see the results come back as clean JSON. In my case, it even found my own website and, as a bonus, it tells you which engine returned the hit (shout-out to “DuckDuckGo“).

    Pretty cool. Pretty simple. And honestly, that’s the whole magic of SearXNG: powerful results without any unnecessary complexity

    Summary

    This was a quick tour of a seriously awesome tool. These days there are plenty of privacy-friendly search engines, you can trust them… or not 🤷‍♂️. The beauty of SearXNG is that you don’t have to: you can just host your own.

    For the OSINT crowd (especially the developer types), this can be a real game-changer. Automate your dorks, feed the results into your local LLM, and suddenly you’ve got clean, filtered intelligence with almost no effort.

    Whatever your use case, I highly recommend giving SearXNG a try. Show the project some love: star it, support it, spread the word, tell your mom about it and tell her I said hi 👋.