<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[BadgerOps]]></title><description><![CDATA[Musings about Infosec, hardware, Salt, Devopsy things and bacon]]></description><link>https://blog.badgerops.net/</link><image><url>https://blog.badgerops.net/favicon.png</url><title>BadgerOps</title><link>https://blog.badgerops.net/</link></image><generator>Ghost 5.70</generator><lastBuildDate>Wed, 20 May 2026 21:06:43 GMT</lastBuildDate><atom:link href="https://blog.badgerops.net/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[Treating OpenClaw Like a Junior Sysadmin]]></title><description><![CDATA[(Or, yet another blog post on playing around with openclaw, just much less hype)]]></description><link>https://blog.badgerops.net/treating-openclaw-like-a-junior-sysadmin/</link><guid isPermaLink="false">6a0e103a093c710001306585</guid><category><![CDATA[NixOS]]></category><category><![CDATA[Homelab]]></category><category><![CDATA[AI]]></category><category><![CDATA[Monitoring]]></category><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Wed, 20 May 2026 20:10:59 GMT</pubDate><content:encoded><![CDATA[<p></p><p>I wanted to kick the tires on <a href="https://openclaw.ai/">OpenClaw</a>, but I did not want to install it directly on my primary workstation.</p><p>That&apos;s not a knock on OpenClaw specifically. It is just the normal posture I try to keep with new automation tools, especially ones that are designed to sit close to my workflow, read context, talk to services, and take actions on my behalf. Before I hand something the keys to my actual daily-driver environment, I want to see how it behaves in a smaller, more boring box.</p><p>So the question became: how do I evaluate an AI companion without turning it into a trusted endpoint?</p><p>The answer I settled on was to treat it like a junior sysadmin.</p><p>Give it an identity. Give it a &quot;workstation&quot;. Give it limited access to logs, metrics, chat, and a couple of source-control systems. Let it observe. Let it help troubleshoot. Do not give it my shell, my browser profile, my SSH agent, or a wide-open view of the homelab.</p><h2 id="the-first-attempt">The First Attempt</h2><p>The first pass was a small VM on one of my internal servers: <code>guiltyspark</code>. That made sense at the time: isolated guest, disposable disk, a narrow network path, and a firewall policy that only allowed what I explicitly needed. The VM was built around a Debian guest with OpenClaw bootstrapped inside it, and the network policy was intentionally grumpy about internal access. Matrix was allowed. The rest of RFC1918 was mostly not.</p><pre><code># This was the original guiltyspark VM shape.
# The bridge could talk to itself, and Matrix was allowed.
# Everything else in RFC1918 got rejected before the final allow.

${iptables} -I FORWARD 1 \
  -i ${openclaw.network.bridge} \
  -o ${openclaw.network.bridge} \
  -j ACCEPT

${iptables} -I FORWARD 2 \
  -i ${openclaw.network.bridge} \
  -d ${openclaw.matrix.ip}/32 \
  -p tcp -m multiport --dports 80,443 \
  -j ACCEPT

${iptables} -I FORWARD 3 -i ${openclaw.network.bridge} -d 10.0.0.0/8 -j REJECT
${iptables} -I FORWARD 4 -i ${openclaw.network.bridge} -d 172.16.0.0/12 -j REJECT
${iptables} -I FORWARD 5 -i ${openclaw.network.bridge} -d 192.168.0.0/16 -j REJECT

# After the explicit internal rejects, normal outbound traffic was okay.
${iptables} -I FORWARD 6 -i ${openclaw.network.bridge} -j ACCEPT
</code></pre><pre><code>                   allowed: tcp/80,443
+---------------+ ------------------------&gt; +-------------------+
| openclaw VM   |                           | matrix.badger.lan |
| Debian guest  |                           | Synapse           |
+---------------+                           +-------------------+
        |
        | blocked: 10/8, 172.16/12, 192.168/16
        v
+---------------------------------------------------------------+
| the rest of the internal network                              |
+---------------------------------------------------------------+
</code></pre><p>That worked well as a security model, but it did not work well as an evaluation environment. The VM (1 core, 4gb ram) felt cramped, and I wanted more horsepower without moving the experiment onto my primary workstation.</p><p>I had a spare laptop sitting around and deployed NixOS, then as a Halo nerd, named it <code>cortana</code>. Good enough. It had actual hardware, it was not my main machine, and it was now part of the NixOS fleet. So Cortana became the OpenClaw host.</p><h2 id="cortana-becomes-the-intern-desk">Cortana Becomes the Intern Desk</h2><p>The first step was, obviously, infrastructure work - Cortana got a real DNS name, <code>cortana.badger.lan</code>, and a static management address from the internal pool.</p><pre><code># common/system/common.nix
# Keep hostnames and management addresses in one shared inventory.

hostnames.cortana = &quot;cortana.badger.lan&quot;;

hosts.cortana = {
  mgmt = &quot;10.170.0.117&quot;; # get it? 117? ok...
};

# modules/infrastructure/coredns.nix
# CoreDNS then renders the LAN A record from that inventory.

cortana IN A ${infraCommon.hosts.cortana.mgmt}

# hosts/cortana/networking.nix
# The host gets the same address statically, instead of relying on DHCP luck.

addresses = &quot;${common.hosts.cortana.mgmt}/24&quot;;
</code></pre><p>Secrets are handled through SOPS. The rendered OpenClaw configuration lands in <code>/home/cortana/.openclaw/openclaw.json</code> with the Matrix credentials and gateway token injected by the system configuration. No room IDs, passwords, or tokens are baked into the repo.</p><pre><code># hosts/cortana/openclaw.nix
# These secret names exist in Git, but the values live encrypted in SOPS.

sops.secrets = {
  &quot;openclaw/matrix-user-id&quot; = { };
  &quot;openclaw/matrix-password&quot; = { };
  &quot;openclaw/matrix-room-id&quot; = { };
  &quot;openclaw/gateway-token&quot; = { };
};

# Render the runtime config as the cortana user.
# The actual token/password placeholders are replaced by sops-nix at activation time.

sops.templates.&quot;openclaw.json&quot; = {
  owner = &quot;cortana&quot;;
  group = &quot;users&quot;;
  mode = &quot;0400&quot;;
};
</code></pre><p>The OpenClaw gateway itself is not running as a user systemd service. That was one of the early pain points. On NixOS, the installer expected a more conventional Linux environment, then we ran into user-bus and package-manager assumptions. The quickstart wanted Node. The Node installer wanted a package manager it recognized. The gateway service wanted systemd user-bus behavior that was not present in the way I was invoking it. Then rootless container namespace setup had its own <code>newuidmap</code> complaints after a reboot.</p><p>None of those problems were individually shocking. They were just enough friction to make the native NixOS path feel like the wrong thing to evaluate first.</p><p>This is primarily because I chose to deploy inside of Distrobox. Cortana is still NixOS, but OpenClaw runs inside a Fedora Distrobox named <code>openclaw</code> because... it&apos;s obvious?. The NixOS module owns the outer lifecycle, creates the box if needed, installs the normal Fedora-side dependencies, runs the OpenClaw installer there, and starts the gateway under a system service on the host.</p><pre><code># Host: NixOS
# Container userland: Fedora via Distrobox
# OpenClaw sees a managable Linux host it expects; NixOS still owns the service lifecycle.

boxName = &quot;openclaw&quot;;
boxImage = &quot;quay.io/fedora/fedora:latest&quot;;

if ! podman container exists ${boxName}; then
  distrobox-create \
    --yes \
    --name ${boxName} \
    --image ${boxImage} \
    --additional-packages &quot;nodejs npm make gcc gcc-c++ cmake python3 chromium git curl tar gzip xz which procps-ng diffutils findutils&quot;
fi

distrobox-enter --name ${boxName} -- bash -lc &apos;
  set -euo pipefail

  # Keep npm global installs under the cortana home directory.
  mkdir -p ${npmPrefix}
  npm config set prefix ${npmPrefix}
  export PATH=&quot;${npmPrefix}/bin:/usr/local/bin:/usr/bin:/bin:$PATH&quot;

  # Let OpenClaw install itself in the Fedora userland.
  if ! openclaw --version &gt;/dev/null 2&gt;&amp;1; then
    curl -fsSL https://openclaw.ai/install.sh | bash
  fi
&apos;
</code></pre><pre><code>+-------------------------------------------------------------+
| cortana.badger.lan                                          |
| NixOS                                                       |
|                                                             |
|  systemd: openclaw-gateway.service                          |
|      |                                                      |
|      v                                                      |
|  distrobox-enter openclaw                                   |
|      |                                                      |
|      v                                                      |
|  Fedora userland: node, npm, chromium, openclaw             |
|                                                             |
|  gateway bind: 127.0.0.1:18789                              |
+-------------------------------------------------------------+
</code></pre><pre><code># The host service is intentionally simple.
# NixOS owns start/stop/restart. OpenClaw runs as cortana.

systemd.services.openclaw-gateway = {
  description = &quot;OpenClaw Gateway (Distrobox)&quot;;
  wantedBy = [ &quot;multi-user.target&quot; ];
  wants = [ &quot;network-online.target&quot; ];
  after = [ &quot;network-online.target&quot; ];

  serviceConfig = {
    User = &quot;cortana&quot;;
    Group = &quot;users&quot;;
    WorkingDirectory = &quot;/home/cortana&quot;;
    Type = &quot;simple&quot;;
    ExecStartPre = openclawBootstrap;
    ExecStart = openclawGatewayStart;
    ExecStop = openclawGatewayStop;
    Restart = &quot;always&quot;;
    RestartSec = &quot;10s&quot;;
  };
};
</code></pre><p>The gateway binds to loopback on port <code>18789</code>. If I want the dashboard, I SSH tunnel to Cortana and open it locally (for now - eventually it may join the rest of my internal services on <a href="https://github.com/gethomepage/homepage">Homepage</a>. OpenClaw also gets a host-side wrapper, so from Cortana I can run <code>openclaw ...</code> and have it enter the Distrobox with the right PATH and internal CA trust.</p><pre><code># Local workstation
# Nothing exposed on the LAN; the dashboard is reached through SSH.

ssh -N -L 18789:127.0.0.1:18789 cortana@cortana.badger.lan

# Then open this locally:
# http://localhost:18789/
</code></pre><h2 id="identity-not-my-identity">Identity, Not My Identity</h2><p>The important bit for me was that OpenClaw should not <em>be</em> me.</p><p>I created separate identities for it: a GitHub profile and a Forgejo profile for my internal git service. Those accounts are intentionally scoped as service-style users. They can be invited to the things I want them to see, and they can be removed or rotated without touching my personal credentials.</p><pre><code># Conceptually, this is the access model I wanted:

human:badgerops
  - primary workstation
  - normal SSH agent
  - broad repo/admin access
  - browser sessions and personal tokens

assistant:openclaw
  - host: cortana
  - Unix user: cortana
  - GitHub user: &lt;wouldn&apos;t you like to know...&gt;
  - Forgejo user: cortana-bot
  - Matrix user: cortana
  - access: selected rooms, selected repos, selected logs/metrics
</code></pre><p>That distinction matters. If this is supposed to act like a junior sysadmin, it should have an account like a junior sysadmin. Not my browser cookies. Not my workstation SSH agent. Not my github or forgejo or <code>${service}</code> token because that is easiest. A little extra work = a little less future me pain. Maybe.</p><p>Right now the goal is not to let it autonomously administer everything. The goal is to let it participate in the troubleshooting loop with enough context to be useful.</p><h2 id="matrix-as-the-control-plane">Matrix As The Control Plane</h2><p>The chat side is Matrix, because that is already where my internal alerting and operational chat lives.</p><p>OpenClaw is configured against my internally hosted Synapse instance at <code>matrix.badger.lan</code>. The Matrix plugin is enabled, encrypted rooms are enabled, and direct messages use pairing. Group access is allowlisted, not open. That means the bot does not get to roam through arbitrary rooms just because it exists on the Matrix homeserver.</p><pre><code># Rendered shape of the Matrix channel config.
# Values shown as placeholders are injected from SOPS.

&quot;channels&quot;: {
  &quot;matrix&quot;: {
    &quot;enabled&quot;: true,
    &quot;homeserver&quot;: &quot;https://matrix.badger.lan&quot;,
    &quot;network&quot;: {
      &quot;dangerouslyAllowPrivateNetwork&quot;: true # it&apos;s _on_ a private network
    },
    &quot;userId&quot;: &quot;${openclaw/matrix-user-id}&quot;,
    &quot;password&quot;: &quot;${openclaw/matrix-password}&quot;,
    &quot;deviceName&quot;: &quot;OpenClaw Gateway&quot;,
    &quot;encryption&quot;: true,
    &quot;dm&quot;: {
      &quot;policy&quot;: &quot;pairing&quot;
    },
    &quot;groupPolicy&quot;: &quot;allowlist&quot;,
    &quot;groups&quot;: {
      &quot;${openclaw/matrix-room-id}&quot;: {
        &quot;enabled&quot;: true
      }
    },
    &quot;autoJoin&quot;: &quot;allowlist&quot;,
    &quot;autoJoinAllowlist&quot;: [
      &quot;${openclaw/matrix-room-id}&quot;
    ]
  }
}
</code></pre><p>For the first real room, I added it to the internal infra room. That is also where the Alertmanager-to-Matrix relay can post alerts, so OpenClaw can see the same operational noise I would normally react to: service health, host issues, OpenClaw&apos;s own gateway state, and the other homelab monitoring signals.</p><pre><code>+----------------+       webhook        +--------------------------+
| Prometheus     | ------------------&gt;  | Alertmanager             |
| alert rules    |                      |                          |
+----------------+                      +------------+-------------+
                                                   |
                                                   | Matrix relay
                                                   v
                                        +--------------------------+
                                        | #infra Matrix room       |
                                        | humans + OpenClaw        |
                                        +--------------------------+
</code></pre><p>There was a small gotcha here: pairing requests can time out. I spent a bit staring at the CLI saying there were no pending Matrix pairings while the chat UI still showed one. Eventually I realized: it had expired. Start over, pair again, move on.</p><h2 id="observability-for-the-assistant">Observability For The Assistant</h2><p>If I am going to run a helper that is supposed to help with operations, I need to be able to observe the helper too.</p><p>The Cortana module exports OpenClaw health into the Node Exporter textfile collector once per minute. It runs <code>openclaw health --json</code> as the <code>cortana</code> user and converts the output into Prometheus metrics. That gives me scrape success, gateway health, event loop delay, event loop utilization, agent sessions, and Matrix channel state.</p><pre><code># Timer: refresh the OpenClaw textfile metrics every minute.

systemd.timers.openclaw-node-exporter-textfile = {
  wantedBy = [ &quot;timers.target&quot; ];
  timerConfig = {
    OnBootSec = &quot;2m&quot;;
    OnUnitActiveSec = &quot;1m&quot;;
    Unit = &quot;openclaw-node-exporter-textfile.service&quot;;
  };
};

# Collector: ask OpenClaw for health JSON as the cortana user.

health_json=&quot;$(${pkgs.util-linux}/bin/runuser -u cortana -- ${openclawWrapper}/bin/openclaw health --json 2&gt;/dev/null)&quot;
</code></pre><pre><code># Example output written to node_exporter&apos;s textfile collector.
# Prometheus scrapes this like any other node metric.

openclaw_scrape_success 1
openclaw_gateway_health_ok 1
openclaw_event_loop_delay_p99_milliseconds 12
openclaw_event_loop_utilization 0.02
openclaw_channel_connected{channel=&quot;matrix&quot;,account=&quot;default&quot;} 1
</code></pre><p>Prometheus scrapes Cortana&apos;s node exporter with <code>role=openclaw</code>. Alert rules watch for stale OpenClaw metrics, failed health scrapes, an unhealthy gateway, and disconnected channels. There is also a Grafana dashboard for the gateway, because eventually every little homelab experiment needs a dashboard. Apparently this is the law.</p><pre><code># Prometheus target. Cortana is just another node_exporter scrape,
# but it gets role=openclaw so dashboards and alerts can filter cleanly.

- targets: [&apos;${globalCommon.hosts.cortana.mgmt}:9100&apos;]
  labels:
    host: cortana
    role: openclaw
</code></pre><pre><code># A few of the OpenClaw-specific alerts.
# These are less about paging me immediately and more about proving the assistant itself is observable.

- alert: OpenClawMetricsStale
  expr: (time() - openclaw_scrape_timestamp_seconds{job=&quot;node-exporter&quot;,host=&quot;cortana&quot;}) &gt; 300
  for: 5m
  labels:
    severity: warning
    category: openclaw

- alert: OpenClawGatewayUnhealthy
  expr: openclaw_gateway_health_ok{job=&quot;node-exporter&quot;,host=&quot;cortana&quot;} == 0
  for: 5m
  labels:
    severity: critical
    category: openclaw

- alert: OpenClawChannelDisconnected
  expr: openclaw_channel_connected{job=&quot;node-exporter&quot;,host=&quot;cortana&quot;} == 0
  for: 5m
  labels:
    severity: warning
    category: openclaw
</code></pre><p>Logs go through <a href="https://vector.dev/">Vector</a> into Loki. OpenClaw writes JSON-ish logs under the Cortana user&apos;s home and temporary runtime paths, Vector normalizes the useful fields, and Loki labels them with host, service, unit, source, and level. One minor tradeoff: Vector runs as root on Cortana (for other host log reasons). I am not thrilled by that, but it is contained to this host and may be worth revisiting later.</p><pre><code># Vector reads both the temporary OpenClaw logs and the durable user logs.

sources.openclaw_files = {
  type = &quot;file&quot;;
  include = [
    &quot;/tmp/openclaw/openclaw-*.log&quot;
    &quot;/home/cortana/.openclaw/logs/*.jsonl&quot;
  ];
  read_from = &quot;end&quot;;
};

# Normalize the labels before sending to Loki.
# This makes queries like {service=&quot;openclaw&quot;,host=&quot;cortana&quot;} work cleanly.

sinks.openclaw_loki = {
  type = &quot;loki&quot;;
  endpoint = &quot;http://loki.badger.lan&quot;;
  labels = {
    host = &quot;{{ host }}&quot;;
    service = &quot;{{ service }}&quot;;
    source = &quot;{{ source_type }}&quot;;
    unit = &quot;{{ unit }}&quot;;
    level = &quot;{{ level }}&quot;;
  };
};
</code></pre><h2 id="what-worked">What Worked</h2><p>The model feels right so far.</p><p>OpenClaw is not sitting on my workstation. It does not inherit my local trust. It has its own host, its own account, its own chat identity, and its own source-control identities. It can see selected operational context, especially Matrix alerts and the logs/metrics I choose to expose.</p><p>Distrobox ended up being the right compromise for this stage. I still get a declarative NixOS host managing the lifecycle, DNS, users, SOPS secrets, systemd service, Prometheus integration, Vector integration, and Grafana provisioning. OpenClaw gets a normal Fedora-ish userland where its installer and Node assumptions are much less weird.</p><p>I&#x2019;ve also been using Cortana as a lightweight engineering assistant around my GitHub and Forgejo work. The first pattern so far is automated <code>clawpatch</code> review: she picks an active repo, runs a read-only code review pass, turns the findings into a Markdown report, commits that report into a dedicated archive repo, and sends me the summary in Matrix.</p><pre><code>cron schedule
  -&gt; choose an active (commits in last year) BadgerOps repo
  -&gt; run clawpatch review
  -&gt; save Markdown report
  -&gt; commit report to forgejo:cortanabot/clawpatch-reports
  -&gt; send summary to Matrix
</code></pre><p>The important part is that this is review-only automation. It does not push branches, open PRs, or publish changes unless I explicitly ask. That keeps the loop useful without letting automation mutate production code behind my back.</p><h2 id="what-still-needs-cleanup">What Still Needs Cleanup</h2><p>The Matrix room configuration started as a single room and the current Nix template still renders a single SOPS-backed room ID into <code>groups</code> and <code>autoJoinAllowlist</code>. I have a multi-room secret shape ready, but the renderer still needs to consume that JSON before I can honestly say the room allowlist is multi-room in the deployed config.</p><pre><code># Current: one SOPS-backed room ID rendered into the allowlist.

&quot;groups&quot;: {
  &quot;${openclaw/matrix-room-id}&quot;: {
    &quot;enabled&quot;: true
  }
}

# Next: render a SOPS-backed JSON object/list for multiple rooms.
# The secret exists, but the Nix template still needs to consume it.

openclaw/matrix-room-ids-json
</code></pre><p>I also want to tighten the source-control permissions further once I know which workflows are useful. The right shape is probably a tiny set of repos, branch-only write permissions where possible, and no credential that would be painful to rotate.</p><p>The next useful test is not whether OpenClaw can answer trivia in chat. I do not care much about that. The useful test is whether it can look at an alert, inspect the narrow set of logs and metrics I exposed, find the likely failing unit or regression, and propose a fix that I can review like any other pull request, same with codebase changes as I continue to work on projects.</p><h3 id="the-workflow-i-want-to-prove">The workflow I want to prove:</h3><pre><code>
1. Alertmanager posts an alert into Matrix
2. OpenClaw sees the alert in an allowlisted room
3. OpenClaw checks only the logs/metrics it has access to
4. OpenClaw proposes a diagnosis and patch
5. Human reviews the change like any other PR
</code></pre><h2 id="the-takeaway">The Takeaway</h2><p>I think this is the pattern I want for AI companion tools in my own infrastructure: do not install them into the most trusted place first. Give them a small desk, a badge with their own name on it, limited read access, and a very boring job.</p><p>In this case, that desk is Cortana. The badge is a Matrix account plus separate GitHub and Forgejo users. The boring job is watching alerts, reading selected logs and metrics, and helping me reason through operational issues.</p><p>That feels a lot better than installing a brand-new assistant directly onto my workstation and hoping the defaults line up with my threat model.</p><p>We&apos;ll see where things end up next!</p><p>All for now,</p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[Why I Wrote a New Terraform Provider for UniFi]]></title><description><![CDATA[I wanted a UniFi Terraform workflow I could trust, so I wrote a new provider built around the current OpenAPI snapshot. It is published now, active, and already past its first hundred downloads.]]></description><link>https://blog.badgerops.net/why-i-wrote-a-new-terraform-provider-for-unifi/</link><guid isPermaLink="false">6a0e18d6093c7100013065f7</guid><category><![CDATA[Terraform]]></category><category><![CDATA[UniFi]]></category><category><![CDATA[networking]]></category><category><![CDATA[Homelab]]></category><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Mon, 18 May 2026 20:51:00 GMT</pubDate><content:encoded><![CDATA[<p>I have been doing more work around local UniFi management lately, and one thing kept bothering me: there was not a Terraform workflow for UniFi that I actually wanted to use.</p><p>(Yes, there are several other UniFi providers out there - but they all basically forked the O.G. provider: <a href="https://github.com/paultyng/terraform-provider-unifi">https://github.com/paultyng/terraform-provider-unifi</a>, and provide a small amount of additional feature support on top of that [now archived] provider. Moving on.)</p><p>UniFi is good when you are clicking around in one controller. It gets less good when you want repeatability. Networks, WiFi broadcasts, firewall rules, DHCP reservations, ACLs. That all turns into remembered UI state unless you put some structure around it.</p><p>So I wrote one.</p><p>The result is <code>badgerops/unifi</code>, a Terraform provider for UniFi Network configuration based on the actual UniFi OpenAPI Spec. It is published on the Terraform Registry, and people are already using it. As of May 20, 2026, there&apos;s been 153 downloads. That is still early, but it is enough to confirm that this was not just a problem I had.</p><h2 id="the-problem-i-was-actually-trying-to-solve">The problem I was actually trying to solve</h2><p>As a recap, I did not start this because I wanted to write a provider for the sake of writing a provider.</p><p>I started because, like any self-respecting Sysadmin, I wanted UniFi configuration to behave more like infrastructure and less like a pile of clicks. I wanted to define site state in code, review changes in a normal workflow, and stop relying on memory and screenshots to understand how a controller was configured.</p><p>I spent time looking at the existing UniFi Terraform options. The short version is not that anybody did something wrong. The original provider was based off <a href="https://github.com/paultyng/go-unifi">go-unifi</a> which was built by decompiling the original Unifi Network application Jar file, and generating code from the json files contained in that Jar. All of the other providers fork and extend on that same basic premise.</p><p>Sometime in recent history, Ubiquiti started shipping an OpenAPI spec file inside their Network application code, so I thought: &#x201C;hey, let&#x2019;s use that!&#x201D;</p><p>I fired up my trusty friend <code>claude</code> and started planning the implementation, giving the existing configuration of my UniFi network, the OpenAPI spec, and a simple instruction: &quot;Go make me a Terraform provider&quot;<br><em>A few hours later</em> - (and much back and forth, redirecting, hand-editing, and mild cussing) I had a functioning provider that showed 0 drift between the previous (paultyng provider) state and the current plan with my own shiny provider.</p><p>On to the nitty gritty!</p><h2 id="what-this-provider-is">What this provider is</h2><p>This provider targets core UniFi Network configuration. This includes things like:</p><ul><li>networks</li><li>WiFi broadcasts</li><li>firewall zones and policies</li><li>firewall policy ordering</li><li>traffic matching lists</li><li>DNS policies</li><li>ACL rules and ACL rule ordering</li><li>DHCP reservations</li></ul><p>It also includes data sources for the lookup and reference objects you need to build useful configurations: sites, devices, networks, WiFi broadcasts, firewall zones and policies, VPN references, DPI applications, countries, RADIUS profiles, device tags, WANs, switch stacks, MC-LAG domains, and LAGs.</p><p>It is intentionally aimed at durable configuration state. <em>It is not trying to be a giant wrapper around every controller action</em>. I am much more interested in the parts of UniFi that benefit from reviewable, repeatable Terraform configuration than in one-off operational actions. (Those should still be clicks, imho)</p><h2 id="why-i-built-it-around-the-openapi-snapshot">Why I built it around the OpenAPI snapshot</h2><p>A big part of the design is that the repository tracks a committed UniFi Network OpenAPI snapshot and generates the low-level client code from that.</p><p>That matters because the UniFi API story is useful, but not perfectly clean. If you want a provider to stay understandable over time, it helps a lot to anchor it to a real contract instead of a bunch of &#x201C;the controller seemed to accept this last time I tried it&#x201D; logic.</p><p>So the provider is shaped around the committed snapshot, and the generated client stays behind explicit translation code. That means the Terraform surface stays intentional. Fields exist because they are supported and mapped on purpose, not because raw JSON happened to leak through.</p><p>The current public release, <code>0.2.12</code>, refreshed that snapshot from UniFi Network <code>10.2.105</code> to <code>10.3.58</code>. That brought in support for newer WiFi schema additions, including open security encryption modes and standard-broadcast DNS assistance configuration.</p><p>This is also part of why I spent time on the gitops tooling around the provider. There is weekly automation to watch upstream UniFi package releases and flag when the committed snapshot likely needs a refresh. That does not magically make maintenance happen, but it does mean staying current is built into the project shape instead of being a vague future intention.</p><h2 id="the-api-reality">The API reality</h2><p>In general, if UniFi (or any other service provider) exposes something in the integration API, that is the surface I want to build against.</p><p>I did not want to turn this into Terraform over random private controller endpoints.</p><blockquote><strong><em>But</em></strong>. DHCP reservations are useful enough - and I needed them - that I made one explicit exception instead of pretending the official surface already covered them. (Because they didn&apos;t)</blockquote><p>The provider is integration-API-first, with one narrow legacy exception for <code>unifi_dhcp_reservation</code>. The reason is the current committed OpenAPI spec does not expose DHCP reservation writes. For that one resource, the provider uses the legacy local Network client database endpoint. Fingers crossed that this changes soon.</p><p>Even there, I still tried to keep the behavior narrow and explicit. The provider is not &#x201C;private API everywhere.&#x201D; It is a small, documented exception where the official surface is not there yet.</p><h2 id="what-it-covers-today">What it covers today</h2><p>Today, the provider gives you a usable base for managing real UniFi site configuration with Terraform. It has generated docs, checked-in examples, Registry-ready release packaging, and tests around the controller behaviors that matter for these resources.</p><p>I also spent time chasing firewall behavior against real controller quirks which consumed... <em>time...</em> That took a while to sort out and figure a good way to report if there is a controller issue. Still trying to figure that out, but for now we just have a bunch of unit tests and log output.</p><p>The fact that it is already past its first hundred downloads is a useful sanity check. It does not make the provider mature overnight, but it does suggest there are other people who want this same shape of workflow.</p><h2 id="a-small-example">A small example</h2><p>This is the kind of configuration I wanted to be able to write:</p><pre><code class="language-hcl">terraform {
  required_providers {
    unifi = {
      source  = &quot;badgerops/unifi&quot;
      version = &quot;0.2.12&quot;
    }
  }
}

provider &quot;unifi&quot; {
  api_url        = var.unifi_api_url
  api_key        = var.unifi_api_key
  allow_insecure = false
}

data &quot;unifi_site&quot; &quot;main&quot; {
  name = &quot;default&quot;
}

resource &quot;unifi_network&quot; &quot;management&quot; {
  site_id    = data.unifi_site.main.id
  name       = &quot;management&quot;
  management = &quot;GATEWAY&quot;
  vlan_id    = 200

  ipv4_configuration = {
    host_ip_address = &quot;10.200.0.1&quot;
    prefix_length   = 24
    dhcp = {
      mode = &quot;SERVER&quot;
      range = {
        start = &quot;10.200.0.10&quot;
        stop  = &quot;10.200.0.200&quot;
      }
    }
  }
}

resource &quot;unifi_dhcp_reservation&quot; &quot;switch&quot; {
  site_id     = data.unifi_site.main.id
  mac_address = &quot;AA:BB:CC:DD:EE:01&quot;
  fixed_ip    = &quot;10.200.0.25&quot;
}</code></pre><p>That is much closer to the workflow I wanted: declare the shape of the site, review the change, apply it, and keep going.</p><h2 id="sharp-edges-because-unifi-is-still-unifi">Sharp edges, because UniFi is still UniFi</h2><p>I do not think a post like this is useful if it pretends the platform is cleaner than it is.</p><p>There are a few important caveats:</p><ul><li>Zone-based firewall needs to be enabled in UniFi before the firewall resources work.</li><li>Controller behavior around firewall rules can be quirky, especially with some combinations of filters and action settings.</li><li>DHCP reservations still use a narrow legacy API exception because the integration API does not expose reservation writes.</li><li>The provider is for configuration workflows, not controller operations like adoption, telemetry, or device lifecycle management.</li></ul><h2 id="why-this-project-matters-to-me">Why this project matters to me</h2><p>This scratches a very real operational itch for me.</p><p>It is useful in my homelab. It is potentially useful in client work. It is also a good exercise in building something carefully around an API surface that is useful, even if imperfect.</p><p>I enjoyed building something that actually solved a problem for people-other-than-me, and, it was good to put my AI friend to work on something other than a task manager (heh)</p><h2 id="what-is-next">What is next</h2><p>The short list from here is pretty straightforward:</p><ul><li>keep expanding resource coverage where the integration API makes sense for Terraform</li><li>keep testing against real controller behavior instead of assuming the docs tell the whole story (Anyone got a large deployment and want to play?)</li><li>reduce surprises around UniFi firewall behavior</li><li>move DHCP reservations back to the official API if UniFi exposes that path later</li><li>keep the provider aligned with future upstream snapshot changes</li></ul><p>The weekly upstream check is part of that maintenance story. It is the best way I could think of that keeps this project from quietly drifting out of date.</p><h2 id="give-it-a-try">Give it a try</h2><p>If you use UniFi and Terraform, give it a try.</p><p>If it breaks in an interesting way, definitely tell me.</p><p>-BadgerOps</p><p>GitHub: <a href="https://github.com/BadgerOps/terraform-provider-unifi">https://github.com/BadgerOps/terraform-provider-unifi</a><br>Terraform Registry: <a href="https://registry.terraform.io/providers/BadgerOps/unifi/latest">https://registry.terraform.io/providers/BadgerOps/unifi/latest</a></p>]]></content:encoded></item><item><title><![CDATA[Building an In-App Auto-Updater for a Containerized NixOS Deployment]]></title><description><![CDATA[What started as a straightforward trigger-file mechanism has evolved through a dozen iterations into something with pre-upgrade backups, separate backend/frontend version tracking, step-by-step progress reporting, and post-upgrade health checks.]]></description><link>https://blog.badgerops.net/building-in-app-auto-updater-containerized-nixos-deployment/</link><guid isPermaLink="false">698b3f98093c71000130653f</guid><category><![CDATA[NixOS]]></category><category><![CDATA[Containers]]></category><category><![CDATA[DevOps]]></category><category><![CDATA[Podman]]></category><category><![CDATA[Homelab]]></category><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Tue, 10 Feb 2026 16:16:00 GMT</pubDate><content:encoded><![CDATA[<p>I&apos;ve been building <a href="https://github.com/BadgerOps/grapheon">Grapheon</a>, a network graph analysis and visualization tool, and deploying it as a pair of Podman containers on NixOS. The stack is pretty straightforward: a FastAPI backend, a React frontend, both published as container images to GitHub Container Registry (GHCR), and wired together with a NixOS module that manages everything through systemd services.</p>
<p>It works great. But every time I cut a new release I&apos;d have to SSH into the box, pull the new images, restart the services. Not exactly the &quot;set it and forget it&quot; experience I was going for.</p>
<p>So I decided to build an in-app auto-updater &#x2014; the kind where you see a little banner saying &quot;hey, there&apos;s a new version&quot; and you click a button and it just... upgrades itself. Sounds simple enough, right?</p>
<p>What started as a straightforward trigger-file mechanism has evolved through a dozen iterations into something with pre-upgrade backups, separate backend/frontend version tracking, step-by-step progress reporting, and post-upgrade health checks. Here&apos;s where it stands today.</p>
<h2 id="the-problem">The Problem</h2>
<p>Here&apos;s the thing about containerized deployments: the app running <em>inside</em> the container can&apos;t exactly restart itself. It doesn&apos;t have access to Podman, it doesn&apos;t know about systemd, and it definitely shouldn&apos;t be pulling its own replacement image. The container is a fish that needs to convince the ocean to swap it out for a different fish.</p>
<p>So the architecture needed two halves:</p>
<ol>
<li><strong>The app side</strong> &#x2014; the backend checks GitHub for new releases, the frontend shows a banner and lets you click &quot;upgrade&quot;</li>
<li><strong>The NixOS side</strong> &#x2014; a systemd path unit watches for a trigger file, kicks off an upgrade handler that backs up data, pulls new images from GHCR, restarts services, and verifies the health of the new deployment</li>
</ol>
<p>The container talks to the host through a shared volume. That&apos;s it. A file on disk is the entire IPC mechanism. Sometimes the simplest approach is the right one.</p>
<h2 id="the-app-side">The App Side</h2>
<h3 id="backend-checking-for-updates">Backend: Checking for Updates</h3>
<p>The backend has an <code>/api/updates</code> router with three endpoints:</p>
<ul>
<li><code>GET /api/updates</code> &#x2014; checks GitHub Releases API for the latest version</li>
<li><code>POST /api/updates/upgrade</code> &#x2014; writes a trigger file to kick off the host-side upgrade</li>
<li><code>GET /api/updates/status</code> &#x2014; reads the upgrade status file so the frontend can poll progress</li>
</ul>
<p>One thing that matters here: Grapheon uses <strong>separate release tags</strong> for backend and frontend &#x2014; <code>backend-v0.8.7</code> and <code>frontend-v0.9.1</code>, for example. The two components version independently, so the update check has to handle each one:</p>
<pre><code class="language-python">def _extract_latest_versions(releases: list[dict]) -&gt; tuple[Optional[str], Optional[str]]:
    &quot;&quot;&quot;
    Extract the latest backend and frontend version tags from releases.
    Returns (backend_version, frontend_version) tuple.
    &quot;&quot;&quot;
    backend_version = None
    frontend_version = None

    for release in releases:
        tag = release.get(&quot;tag_name&quot;, &quot;&quot;)
        if release.get(&quot;prerelease&quot;, False):
            continue
        if tag.startswith(&quot;backend-v&quot;) and backend_version is None:
            backend_version = tag
        elif tag.startswith(&quot;frontend-v&quot;) and frontend_version is None:
            frontend_version = tag
        if backend_version and frontend_version:
            break

    return backend_version, frontend_version
</code></pre>
<p>The actual GitHub API call is straightforward &#x2014; hit the releases endpoint, cache for an hour, fall back to stale cache if the API fails:</p>
<pre><code class="language-python">CACHE_TTL_SECONDS = 3600  # 1 hour

async def _fetch_github_releases() -&gt; Optional[list[dict]]:
    async with httpx.AsyncClient() as client:
        response = await client.get(
            &quot;https://api.github.com/repos/BadgerOps/grapheon/releases&quot;,
            timeout=10.0,
        )
        response.raise_for_status()
        return response.json()
</code></pre>
<p>The version comparison is tuple-based &#x2014; parse <code>&quot;backend-v0.8.7&quot;</code> into <code>(0, 8, 7)</code>, compare with the current version, done. The parser strips the <code>backend-v</code> or <code>frontend-v</code> prefix before splitting on dots.</p>
<p>One fun bug I hit early on: the frontend version kept coming back as <code>None</code> because the update check was only reading the <code>FRONTEND_VERSION</code> environment variable, which isn&apos;t always set. The fix was adding a <code>_detect_frontend_version()</code> function that checks the env var first, then falls back to reading <code>frontend/package.json</code>. Small thing, but it meant the update check was silently skipping the frontend comparison entirely.</p>
<h3 id="backend-triggering-an-upgrade">Backend: Triggering an Upgrade</h3>
<p>When a user clicks &quot;Upgrade now&quot; in the UI, the backend writes a JSON file to <code>/data/upgrade-requested</code>. The key evolution from the initial version is that it now writes <strong>separate version fields</strong> for backend and frontend:</p>
<pre><code class="language-python">@router.post(&quot;/upgrade&quot;)
async def trigger_upgrade():
    # Check if already upgrading
    status_file = os.path.join(DATA_DIR, &quot;upgrade-status.json&quot;)
    if os.path.exists(status_file):
        status_data = json.load(open(status_file))
        if status_data.get(&quot;status&quot;) == &quot;running&quot;:
            raise HTTPException(status_code=409, detail=&quot;An upgrade is already in progress&quot;)

    # Extract separate backend/frontend target versions
    backend_tag, frontend_tag = _extract_latest_versions(releases)

    upgrade_request = {
        &quot;requested_at&quot;: datetime.utcnow().isoformat() + &quot;Z&quot;,
        &quot;current_version&quot;: settings.APP_VERSION,
        &quot;target_backend_version&quot;: target_backend_version,
        &quot;target_frontend_version&quot;: target_frontend_version,
    }
    with open(os.path.join(DATA_DIR, &quot;upgrade-requested&quot;), &quot;w&quot;) as f:
        json.dump(upgrade_request, f, indent=2)
</code></pre>
<p>That <code>/data</code> directory is a bind mount from the host&apos;s <code>/srv/grapheon/data</code>. So when the container writes <code>upgrade-requested</code>, it appears on the host filesystem. And that&apos;s where systemd picks it up.</p>
<p>Writing separate version fields fixed a real problem &#x2014; when backend was at v0.8.6 and frontend was at v0.9.1, the old code would try to pull <code>grapheon-frontend:v0.8.6</code>, which didn&apos;t exist. &quot;Manifest unknown&quot; errors at 11pm are not fun.</p>
<h3 id="frontend-the-update-banner">Frontend: The Update Banner</h3>
<p>The React frontend has two places where updates surface. First, an <code>UpdateBanner</code> component that lives at the top of the app and polls <code>/api/updates</code> every 60 minutes:</p>
<pre><code class="language-jsx">const POLL_INTERVAL = 60 * 60 * 1000; // 60 minutes

useEffect(() =&gt; {
    checkForUpdatesHandler();
    pollIntervalRef.current = setInterval(checkForUpdatesHandler, POLL_INTERVAL);
    return () =&gt; clearInterval(pollIntervalRef.current);
}, []);
</code></pre>
<p>When an update is available, you get a blue gradient banner with &quot;What&apos;s new&quot; (expands to show release notes) and &quot;Upgrade now.&quot; The banner is dismissible per-version via localStorage, so it won&apos;t nag you if you choose to skip a release.</p>
<p>The upgrade flow goes through a few states: <code>null</code> &#x2192; <code>confirm</code> &#x2192; <code>in_progress</code> &#x2192; <code>completed</code> (auto-refresh after 3 seconds) or <code>error</code> (with retry). What changed since the first version is the <code>in_progress</code> state &#x2014; it now shows a <strong>step-by-step progress timeline</strong> instead of just a spinner:</p>
<pre><code class="language-jsx">statusPollIntervalRef.current = setInterval(async () =&gt; {
    const statusResponse = await getUpgradeStatus();

    if (statusResponse.status === &apos;running&apos;) {
        // upgradeProgress is now a structured object
        setUpgradeProgress({
            message: statusResponse.message,
            step: statusResponse.step,
            totalSteps: statusResponse.total_steps,
            progress: statusResponse.progress,
        });
    } else if (statusResponse.status === &apos;completed&apos;) {
        clearInterval(statusPollIntervalRef.current);
        setUpgradeStep(&apos;completed&apos;);
        setTimeout(() =&gt; window.location.reload(), 3000);
    } else if (statusResponse.status === &apos;failed&apos;) {
        clearInterval(statusPollIntervalRef.current);
        setUpgradeStep(&apos;error&apos;);
    }
}, 5000);
</code></pre>
<p>The UI renders an animated progress bar showing completion percentage, a &quot;Step N/5&quot; counter, and a visual timeline with checkmarks for completed steps, a pulsing dot for the active step, and dimmed indicators for pending steps. It&apos;s a much better experience than staring at &quot;Upgrading...&quot; and wondering if anything is happening.</p>
<p>Second, there&apos;s a &quot;Check for Updates&quot; button on the Settings page with a modal that shows version comparison (with separate badges for UI and API versions), release notes, release date, and a GitHub link. Same upgrade flow, just triggered manually instead of by the polling interval.</p>
<h2 id="the-nixos-side">The NixOS Side</h2>
<p>This is where the real fun begins. The NixOS module (<code>grapheon.nix</code>) manages everything: the Podman network, both containers, a cloudflared tunnel, authentication credentials, and the auto-update machinery.</p>
<h3 id="dynamic-tags-the-version-state-file">Dynamic Tags: The Version State File</h3>
<p>The first thing I had to sort out was how to avoid hardcoding image tags in the nix config. If the NixOS module says <code>ghcr.io/badgerops/grapheon-backend:v0.1.0</code>, then that&apos;s what systemd starts, and you&apos;d need a <code>nixos-rebuild</code> to change it. That defeats the whole point of an auto-updater.</p>
<p>The solution is a version state file. The nix module defines a <code>defaultTag</code> (used only on first boot), but after that, everything reads from <code>/srv/grapheon/data/current-tag</code>:</p>
<pre><code class="language-nix">let
  defaultTag = &quot;v0.3.0&quot;;
  versionFile = &quot;${dataDirDb}/current-tag&quot;;

  readTag = &apos;&apos;
    if [ -f &quot;${versionFile}&quot; ]; then
      GRAPHEON_TAG=&quot;$(${pkgs.coreutils}/bin/cat &quot;${versionFile}&quot; | ${pkgs.coreutils}/bin/tr -d &apos;[:space:]&apos;)&quot;
    else
      GRAPHEON_TAG=&quot;${defaultTag}&quot;
    fi
  &apos;&apos;;
</code></pre>
<p>Each container service uses a wrapper script instead of inlining the <code>podman run</code> command. The wrapper reads the tag at start time:</p>
<pre><code class="language-nix">backendStartScript = pkgs.writeShellScript &quot;grapheon-backend-start&quot; &apos;&apos;
    set -euo pipefail
    ${readTag}
    echo &quot;Starting grapheon-backend with tag: $GRAPHEON_TAG&quot;
    exec ${pkgs.podman}/bin/podman run \
      --rm \
      --name=grapheon-backend \
      --network=${grapheonNetwork} \
      --network-alias=grapheon-backend \
      --hostname=grapheon-backend \
      -v ${dataDirDb}:/data:Z \
      --env-file ${grapheonAuthEnvFile} \
      -e DATABASE_URL=sqlite:////data/network.db \
      -e APP_NAME=Grapheon \
      -e AUTH_ENABLED=True \
      -e ENFORCE_AUTH=True \
      -e JWT_ALGORITHM=HS256 \
      -e JWT_EXPIRATION_MINUTES=60 \
      --label io.containers.autoupdate=registry \
      ${backendImageBase}:$GRAPHEON_TAG
&apos;&apos;;
</code></pre>
<p>Notice there&apos;s no <code>-e APP_VERSION</code> being injected. That was actually a bug in my first iteration &#x2014; the nix config was passing the hardcoded version as an env var, which overrode whatever version the container image had baked in. The backend&apos;s <code>config.py</code> already reads a <code>VERSION</code> file from inside the container, so we just let it do its thing.</p>
<p>Also notice the auth-related environment variables and <code>--env-file</code> &#x2014; Grapheon picked up OIDC and local admin authentication along the way, and those credentials get injected from a separate env file on the host that the NixOS activation script creates on first deploy.</p>
<h3 id="the-auto-update-script-daily-timer">The Auto-Update Script (Daily Timer)</h3>
<p>The NixOS module has an inline auto-update script for the daily timer that handles the GHCR query and image pull:</p>
<pre><code class="language-nix">autoUpdateScript = pkgs.writeShellScript &quot;grapheon-auto-update&quot; &apos;&apos;
    set -euo pipefail

    # GHCR requires an anonymous bearer token even for public images
    token=&quot;$(${pkgs.curl}/bin/curl -fsSL \
      &apos;https://ghcr.io/token?scope=repository:badgerops/grapheon-backend:pull&apos; \
      | ${pkgs.jq}/bin/jq -r &apos;.token&apos; \
    )&quot;

    latest_tag=&quot;$(${pkgs.curl}/bin/curl -fsSL \
      -H &quot;Authorization: Bearer $token&quot; \
      https://ghcr.io/v2/badgerops/grapheon-backend/tags/list \
      | ${pkgs.jq}/bin/jq -r &apos;.tags[]&apos; \
      | ${pkgs.gnugrep}/bin/grep -E &apos;^v[0-9]+\.[0-9]+\.[0-9]+$&apos; \
      | ${pkgs.coreutils}/bin/sort -V \
      | ${pkgs.coreutils}/bin/tail -n1 \
    )&quot;

    # Compare against what we&apos;re running
    ${readTag}
    if [ &quot;$GRAPHEON_TAG&quot; = &quot;$latest_tag&quot; ]; then
      echo &quot;Already running $latest_tag &#x2014; nothing to do&quot;
      exit 0
    fi

    # Pull both images
    ${pkgs.podman}/bin/podman pull ${backendImageBase}:$latest_tag
    ${pkgs.podman}/bin/podman pull ${frontendImageBase}:$latest_tag

    # Persist the new tag &#x2014; services read this on next start
    ${pkgs.coreutils}/bin/echo &quot;$latest_tag&quot; &gt; &quot;${versionFile}&quot;

    # Restart picks up the new tag from the state file
    systemctl restart podman-grapheon-backend.service
    systemctl restart podman-grapheon-frontend.service
&apos;&apos;;
</code></pre>
<p>Note the fully-qualified Nix store paths (<code>${pkgs.curl}/bin/curl</code> instead of bare <code>curl</code>) &#x2014; this is how NixOS scripts ensure they use the exact versions of tools declared in the system configuration, not whatever happens to be on <code>$PATH</code>.</p>
<h3 id="the-upgrade-handler-script-grapheon-upgradesh">The Upgrade Handler Script (grapheon-upgrade.sh)</h3>
<p>The in-app upgrade trigger runs through a more evolved path than the daily auto-update. The Grapheon repo includes a standalone <code>scripts/grapheon-upgrade.sh</code> that implements a <strong>five-step upgrade process</strong> with granular status reporting:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# grapheon-upgrade.sh &#x2014; Host-level upgrade watcher script
set -euo pipefail

DATA_DIR=&quot;${DATA_DIR:-/data}&quot;
REQUEST_FILE=&quot;${DATA_DIR}/upgrade-requested&quot;
STATUS_FILE=&quot;${DATA_DIR}/upgrade-status.json&quot;
BACKUP_DIR=&quot;${DATA_DIR}/backups&quot;
HEALTH_URL=&quot;http://localhost:8000/api/health&quot;
TOTAL_STEPS=5
</code></pre>
<p>The script reads <strong>separate backend and frontend versions</strong> from the trigger file, with backward-compat fallback to the old single <code>target_version</code> field:</p>
<pre><code class="language-bash">read -r BACKEND_VERSION FRONTEND_VERSION &lt; &lt;(python3 -c &quot;
import json, sys
try:
    data = json.load(open(&apos;${REQUEST_FILE}&apos;))
    bv = data.get(&apos;target_backend_version&apos;, data.get(&apos;target_version&apos;, &apos;&apos;))
    fv = data.get(&apos;target_frontend_version&apos;, bv)
    print(bv, fv)
except Exception as e:
    print(&apos;&apos;, file=sys.stderr)
    sys.exit(1)
&quot; 2&gt;/dev/null || echo &quot;&quot;)
</code></pre>
<p>Then it runs through five steps, writing structured JSON status after each one so the frontend can track progress:</p>
<p><strong>Step 1: Back up data.</strong> Before touching anything, tar up the SQLite database, WAL, config, and env files to <code>/data/backups/grapheon-backup-YYYY-MM-DD-HHMMSS.tar.gz</code>. If the upgrade goes sideways, you&apos;ve got a snapshot.</p>
<p><strong>Step 2: Pull backend image.</strong> <code>podman pull ghcr.io/badgerops/grapheon-backend:v${BACKEND_VERSION}</code> with a 5-minute timeout.</p>
<p><strong>Step 3: Pull frontend image.</strong> <code>podman pull ghcr.io/badgerops/grapheon-frontend:v${FRONTEND_VERSION}</code> &#x2014; pulled separately with its own version tag.</p>
<p><strong>Step 4: Restart services.</strong> <code>systemctl restart</code> both containers.</p>
<p><strong>Step 5: Health check.</strong> Curl the <code>/api/health</code> endpoint every second for up to 30 seconds. If it never responds, the upgrade is marked as failed.</p>
<p>Each step writes a status update that includes step progress:</p>
<pre><code class="language-bash">write_status() {
    local status=&quot;$1&quot; step=&quot;$2&quot; msg=&quot;$3&quot;
    local progress=$(( (step * 100) / TOTAL_STEPS ))
    [[ &quot;${status}&quot; == &quot;completed&quot; ]] &amp;&amp; progress=100
    cat &gt; &quot;${STATUS_FILE}&quot; &lt;&lt;EOF
{
    &quot;status&quot;: &quot;${status}&quot;,
    &quot;message&quot;: &quot;${msg}&quot;,
    &quot;step&quot;: ${step},
    &quot;total_steps&quot;: ${TOTAL_STEPS},
    &quot;progress&quot;: ${progress},
    &quot;updated_at&quot;: &quot;$(date -u +%Y-%m-%dT%H:%M:%SZ)&quot;
}
EOF
}
</code></pre>
<h3 id="the-ghcr-authentication-saga">The GHCR Authentication Saga</h3>
<p>This one bit me. My first version of the script just curled the GHCR v2 API directly:</p>
<pre><code class="language-bash">curl -fsSL https://ghcr.io/v2/badgerops/grapheon-backend/tags/list
</code></pre>
<p>401 Unauthorized. Even though the images are public.</p>
<p>Turns out GHCR implements the <a href="https://docs.docker.com/registry/spec/auth/token/">Docker Registry v2 authentication spec</a>, which requires a token exchange even for anonymous access to public repositories. You have to:</p>
<ol>
<li>Hit <code>https://ghcr.io/token?scope=repository:OWNER/REPO:pull</code> to get an anonymous bearer token</li>
<li>Pass that token as <code>Authorization: Bearer $token</code> on subsequent API calls</li>
</ol>
<p>One of those things that makes total sense in retrospect but is completely non-obvious when you&apos;re staring at a 401 from a public registry at 11pm.</p>
<h3 id="systemd-path-unit-the-glue">Systemd Path Unit: The Glue</h3>
<p>The bridge between &quot;container wrote a file&quot; and &quot;host pulls new images&quot; is a systemd path unit:</p>
<pre><code class="language-nix">systemd.paths.grapheon-upgrade-trigger = {
    description = &quot;Watch for Grapheon in-app upgrade request&quot;;
    wantedBy = [ &quot;paths.target&quot; ];
    pathConfig = {
        PathExists = &quot;${dataDirDb}/upgrade-requested&quot;;
        Unit = &quot;grapheon-upgrade-watcher.service&quot;;
    };
};
</code></pre>
<p>When <code>/srv/grapheon/data/upgrade-requested</code> appears, systemd activates the <code>grapheon-upgrade-watcher</code> service, which runs the upgrade handler. In the NixOS module, this is currently an inline wrapper that calls the auto-update script with status reporting:</p>
<pre><code class="language-nix">upgradeHandlerScript = pkgs.writeShellScript &quot;grapheon-upgrade-handler&quot; &apos;&apos;
    set -euo pipefail

    STATUS_FILE=&quot;${dataDirDb}/upgrade-status.json&quot;
    TRIGGER_FILE=&quot;${dataDirDb}/upgrade-requested&quot;

    write_status() {
      ${pkgs.coreutils}/bin/echo &quot;$1&quot; &gt; &quot;$STATUS_FILE&quot;
    }

    write_status &quot;{\&quot;status\&quot;:\&quot;running\&quot;,\&quot;message\&quot;:\&quot;Pulling latest images from GHCR...\&quot;,\&quot;started_at\&quot;:\&quot;$(${pkgs.coreutils}/bin/date -Iseconds)\&quot;}&quot;

    # Remove trigger so the path unit re-arms
    ${pkgs.coreutils}/bin/rm -f &quot;$TRIGGER_FILE&quot;

    if ${autoUpdateScript}; then
      write_status &quot;{\&quot;status\&quot;:\&quot;completed\&quot;,\&quot;message\&quot;:\&quot;Upgrade completed successfully.\&quot;,\&quot;completed_at\&quot;:\&quot;$(${pkgs.coreutils}/bin/date -Iseconds)\&quot;}&quot;
    else
      write_status &quot;{\&quot;status\&quot;:\&quot;failed\&quot;,\&quot;message\&quot;:\&quot;Auto-update script exited with an error. Check journalctl -u grapheon-upgrade-watcher for details.\&quot;,\&quot;completed_at\&quot;:\&quot;$(${pkgs.coreutils}/bin/date -Iseconds)\&quot;}&quot;
    fi
&apos;&apos;;
</code></pre>
<p>The next step is migrating this inline handler to call <code>grapheon-upgrade.sh</code> instead, which would bring the NixOS in-app upgrade path in line with the standalone script&apos;s five-step backup-pull-restart-healthcheck flow. For now, the daily timer uses the inline script (which queries GHCR for the latest tag), and the in-app path uses the wrapper above.</p>
<p>There&apos;s also a daily timer for unattended updates:</p>
<pre><code class="language-nix">systemd.timers.grapheon-auto-update = {
    description = &quot;Daily Grapheon auto-update check&quot;;
    wantedBy = [ &quot;timers.target&quot; ];
    timerConfig = {
        OnCalendar = &quot;daily&quot;;
        Unit = &quot;grapheon-auto-update.service&quot;;
        Persistent = true;
    };
};
</code></pre>
<p>This runs the auto-update script on a schedule, so even if nobody is looking at the UI, the deployment stays current.</p>
<h2 id="two-tag-schemes">Two Tag Schemes</h2>
<p>One thing worth calling out: there are two different tag patterns in play.</p>
<p><strong>GHCR container tags</strong> use a plain <code>v</code> prefix: <code>v0.3.0</code>, <code>v0.8.7</code>. The NixOS daily auto-update script queries these from the GHCR v2 tags API and filters for <code>^v[0-9]+\.[0-9]+\.[0-9]+$</code>. Both backend and frontend containers get tagged with the same version when CI publishes them.</p>
<p><strong>GitHub release tags</strong> use component prefixes: <code>backend-v0.8.7</code>, <code>frontend-v0.9.1</code>. The in-app update check queries these from the GitHub Releases API. Backend and frontend can version independently here &#x2014; the frontend might be at v0.9.1 while the backend is at v0.8.7.</p>
<p>The in-app upgrade writes the separate versions to the trigger file, and the upgrade handler pulls each image with its correct tag. The daily auto-update uses a single GHCR tag for both. This works because CI publishes matching images to GHCR under the unified tag, even while GitHub releases track them separately.</p>
<h2 id="the-full-picture">The Full Picture</h2>
<p>Here&apos;s the flow when someone clicks &quot;Upgrade now&quot;:</p>
<ol>
<li>Frontend calls <code>POST /api/updates/upgrade</code></li>
<li>Backend writes <code>/data/upgrade-requested</code> with separate <code>target_backend_version</code> and <code>target_frontend_version</code> fields (bind-mounted from host)</li>
<li>Systemd path unit detects the file, activates the upgrade handler</li>
<li>Handler writes <code>{&quot;status&quot;:&quot;running&quot;,&quot;step&quot;:1,&quot;total_steps&quot;:5,&quot;progress&quot;:20}</code> &#x2014; <strong>Step 1: Backing up data</strong></li>
<li>Handler creates a tar.gz backup of the database and config files</li>
<li>Handler deletes the trigger file (re-arms the path unit)</li>
<li><strong>Step 2:</strong> Pull backend image from GHCR with the backend version tag</li>
<li><strong>Step 3:</strong> Pull frontend image from GHCR with the frontend version tag</li>
<li><strong>Step 4:</strong> Restart both Podman services</li>
<li><strong>Step 5:</strong> Health check &#x2014; poll <code>/api/health</code> until it responds (up to 30s)</li>
<li>Handler writes <code>{&quot;status&quot;:&quot;completed&quot;,&quot;step&quot;:5,&quot;total_steps&quot;:5,&quot;progress&quot;:100}</code></li>
<li>Frontend polls <code>GET /api/updates/status</code>, renders the progress bar and step timeline, sees &quot;completed,&quot; auto-refreshes</li>
<li>User sees the new version. Hopefully.</li>
</ol>
<p>If any step fails, the handler writes <code>{&quot;status&quot;:&quot;failed&quot;,&quot;step&quot;:N}</code> with a message, and the frontend shows the error with a retry button.</p>
<p>The entire IPC is two files on a shared volume. No message queues, no sockets, no D-Bus. Just a trigger file and a status file. The container doesn&apos;t need any special privileges, and the host-side scripts are pure NixOS &#x2014; fully declarative, reproducible, and auditable.</p>
<h2 id="lessons-learned">Lessons Learned</h2>
<p><strong>GHCR auth is not optional.</strong> Even for public images, you need the token dance. Don&apos;t assume anonymous access works like Docker Hub.</p>
<p><strong>File-based IPC is underrated.</strong> systemd path units are built for exactly this kind of thing. They&apos;re reliable, they re-arm automatically, and they require zero custom infrastructure.</p>
<p><strong>A version state file beats hardcoded tags.</strong> Instead of pinning an image tag in your nix config and retagging at runtime (gross) or requiring a <code>nixos-rebuild</code> for every release, just store the current tag in a file on disk. The service wrapper reads it at start time, and the auto-update script writes it before restarting. The nix module&apos;s <code>defaultTag</code> is only there for first boot.</p>
<p><strong>Don&apos;t override what the container already knows.</strong> My first pass had the nix config injecting <code>-e APP_VERSION=0.1.0</code> into the container. The backend already reads its version from a <code>VERSION</code> file baked into the image, but the env var trumped it. So even after a successful upgrade to v0.3.0, the UI still showed 0.1.0. The fix was just... deleting the env var and letting the container report its own version.</p>
<p><strong>Version your components independently.</strong> The backend and frontend don&apos;t always change in lockstep. Early on, I used a single version tag for both, which caused &quot;manifest unknown&quot; errors when they diverged. Now the trigger file carries <code>target_backend_version</code> and <code>target_frontend_version</code> separately, and the upgrade script pulls each with its own tag. The fallback to the old single <code>target_version</code> field keeps it backward-compatible.</p>
<p><strong>Back up before you upgrade.</strong> The pre-upgrade backup was added after a close call. It&apos;s a tar.gz of the SQLite database, WAL file, and config &#x2014; takes milliseconds and gives you a rollback point. Cheap insurance.</p>
<p><strong>Health checks close the loop.</strong> The original version would restart the services and call it done. But &quot;services restarted&quot; doesn&apos;t mean &quot;services healthy.&quot; Adding a 30-second health check loop that polls <code>/api/health</code> catches startup failures that would otherwise silently leave the app down.</p>
<p><strong>Show progress, not just status.</strong> The first version just showed &quot;Upgrading...&quot; with a spinner. Now the frontend renders a progress bar, a step counter (&quot;Step 3/5&quot;), and a visual timeline with checkmarks. Users don&apos;t wonder if it&apos;s stuck anymore.</p>
<p><strong>Cache with a fallback.</strong> The 1-hour cache on GitHub API responses means we&apos;re not rate-limited, and falling back to stale cache on API failure means the update check never hard-fails from the user&apos;s perspective.</p>
<p><strong>Status polling is fine.</strong> I considered WebSockets for the upgrade progress, but polling every 5 seconds is plenty responsive for an operation that takes 30-60 seconds. Keep it simple.</p>
<p>The whole thing has grown from the initial implementation to roughly 500 lines of Python, 550 lines of React, 170 lines of standalone shell script, and 300 lines of Nix. Not bad for a feature that means I never have to SSH in to deploy again.</p>
<p>This grew from just a whim &apos;hey, how can I make this auto-update&apos; to &apos;hey, it would be cool if I could do in-app updates&apos; to the current iteration. I think I&apos;ll create a git repo &amp; blog post around how to implement this in <em>your</em> application, for ease of reference.</p>
<p>All for now,</p>
<p>-BadgerOps</p>
]]></content:encoded></item><item><title><![CDATA[Introducing Graphēon]]></title><description><![CDATA["Network enumeration shouldn't require a PhD in Visio. So I built something."]]></description><link>https://blog.badgerops.net/introducing-grapheon/</link><guid isPermaLink="false">6989fca2093c710001306513</guid><category><![CDATA[infosec]]></category><category><![CDATA[tools]]></category><category><![CDATA[networking]]></category><category><![CDATA[open-source]]></category><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Mon, 09 Feb 2026 15:31:43 GMT</pubDate><content:encoded><![CDATA[<hr><p>Long time, no post! (I know, I know.)</p><p>So. You&apos;re a blue teamer, or a red teamer, or maybe just the person in the room who got volun-told to &quot;map out the network.&quot; You fire up nmap, run some scans, maybe throw in some&#xA0;<code>arp -a</code>&#xA0;and&#xA0;<code>netstat</code>&#xA0;output for good measure. And then... you stare at a wall of XML, CSV, and text output trying to mentally correlate which hosts talk to which other hosts, what services are where, and how it all fits together.</p><p>Sound familiar?</p><h2 id="the-conversation">The Conversation</h2><p>This project started, as many things do, with a conversation with a co-worker. We were talking about the struggle of building a network map when you&apos;re enumerating a new environment. Whether you&apos;re on the defensive side trying to understand what you&apos;re protecting, or on the offensive side trying to figure out what&apos;s interesting - the problem is the same:</p><p>You have&#xA0;<em>multiple</em>&#xA0;tools generating&#xA0;<em>multiple</em>&#xA0;outputs in&#xA0;<em>multiple</em>&#xA0;formats, and somehow you need to correlate all of that into something resembling a coherent picture of the network.</p><p>The typical workflow looks something like this:</p><ol><li>Run nmap scans</li><li>Maybe grab some netstat output from hosts you have access to</li><li>Throw in some arp tables</li><li>Possibly a traceroute or two</li><li>Open up Visio, draw.io, or (even better?) a whiteboard</li><li>Manually start connecting dots</li></ol><p>And by &quot;connecting dots&quot; I mean squinting at IP addresses across 14 terminal tabs and praying you don&apos;t accidentally mistype&#xA0;<code>192.168.1.14</code>&#xA0;as&#xA0;<code>192.168.1.41</code>&#xA0;in your diagram.</p><p>Has anyone actually enjoyed this process? Ever?</p><h2 id="enter-graph%C4%93on">Enter Graph&#x113;on</h2><p><a href="https://github.com/BadgerOps/grapheon">Graph&#x113;on</a>&#xA0;is a tool designed to help quickstart the network enumeration process using standard tooling and correlation. The idea is simple: you feed it the output from tools you&apos;re&#xA0;<em>already using</em>&#xA0;- nmap, netstat, arp, ping, traceroute, pcap - and it normalizes, tags, and correlates that data into an interactive network graph.</p><p>No more copy-pasting IP addresses between terminal windows. No more manually drawing boxes in Visio at 2am.</p><p>The stack is FastAPI + SQLite on the backend and Vite + React on the frontend. Python 3.12. Nothing exotic, nothing that requires a cluster of 47 microservices to deploy.</p><h3 id="what-it-does">What it does</h3><ul><li><strong>Ingests</strong>&#xA0;scan outputs from nmap, netstat, arp, ping, traceroute, and pcap files</li><li><strong>Normalizes</strong>&#xA0;the data - because every tool has its own&#xA0;<em>special</em>&#xA0;way of reporting the same information</li><li><strong>Tags</strong>&#xA0;entities and correlates related hosts across different scan sources</li><li><strong>Visualizes</strong>&#xA0;the resulting topology as an interactive network graph</li><li><strong>Exports</strong>&#xA0;to GraphML (for Gephi, yEd, Cytoscape) or draw.io format</li></ul><p>That last point is important. Graph&#x113;on isn&apos;t trying to&#xA0;<em>replace</em>&#xA0;your favorite graph tool. It&apos;s trying to get you from &quot;pile of scan data&quot; to &quot;usable network map&quot; as fast as possible, and then let you take that map wherever you need it.</p><h3 id="why-the-name">Why the name?</h3><p>Naming is hard. So I asked my good friend Claude for some help, after throwing a bunch of ideas at it. The name evokes graphing and mapping. The project fuses disparate network signals into a coherent graph of hosts, edges, and topology. Also, it sounds cool and the domain wasn&apos;t taken. (Priorities.)</p><h2 id="getting-started">Getting Started</h2><p>Graph&#x113;on runs as two Docker containers - a backend and a frontend. The frontend proxies&#xA0;<code>/api</code>&#xA0;requests to the backend. Deployment is pretty straightforward:</p><pre><code class="language-bash"># Pull images
docker pull ghcr.io/badgerops/grapheon-backend:latest
docker pull ghcr.io/badgerops/grapheon-frontend:latest

# Run backend
docker run -d --name grapheon-backend \
  -p 8000:8000 \
  -v grapheon-data:/app/data \
  -e JWT_SECRET=&quot;$(openssl rand -hex 32)&quot; \
  -e LOCAL_ADMIN_USERNAME=admin \
  -e LOCAL_ADMIN_EMAIL=admin@example.com \
  -e LOCAL_ADMIN_PASSWORD=changeme \
  ghcr.io/badgerops/grapheon-backend:latest

# Run frontend
docker run -d --name grapheon-frontend \
  -p 8080:8080 \
  --link grapheon-backend:grapheon-backend \
  ghcr.io/badgerops/grapheon-frontend:latest
</code></pre><p>Hit&#xA0;<code>http://localhost:8080</code>&#xA0;and you&apos;re in business.</p><p>(And yes, please change the default password. I&apos;ve&#xA0;<a href="https://blog.badgerops.net/cves/">learned a thing or two</a>&#xA0;about hard-coded creds.)</p><p>It also supports OIDC authentication with Okta, Google, GitHub, GitLab, and Authentik if you want proper multi-user RBAC. Check out the&#xA0;<code>docs/auth_provider.md</code>&#xA0;in the repo for that setup.</p><h2 id="current-state-whats-next">Current State &amp; What&apos;s Next</h2><p>Graph&#x113;on is currently at v0.8.x - it&apos;s usable, it&apos;s useful, but it&apos;s not &quot;done&quot; (is any project ever done?). There are&#xA0;<a href="https://github.com/BadgerOps/grapheon/issues">open issues</a>&#xA0;and plenty of room for improvement.</p><p>If you&apos;re someone who regularly deals with network enumeration - whether as a pen tester, SOC analyst, incident responder, or that one infra person who inherited a network with zero documentation - give it a spin. File issues. Submit PRs. Tell me what&apos;s broken.</p><p>The project is BSD-2-Clause licensed, because sharing is caring.</p><h2 id="the-tldr">The TL;DR</h2><p>Network enumeration produces a lot of data from a lot of tools. Graph&#x113;on takes that data and turns it into a network graph so you can stop playing &quot;human correlator&quot; and start actually analyzing your network.</p><p>Check it out:&#xA0;<a href="https://github.com/BadgerOps/grapheon">https://github.com/BadgerOps/grapheon</a></p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[Decoding Kubernetes Secrets with jq]]></title><description><![CDATA[<h1 id></h1><p>It&apos;s been a while since I&apos;ve posted, and I generally post about things I&apos;ve learned / have helped me in my day to day role. This is a quick blog post about easily decoding base64 encoded secrets in kubernetes, using Kubectl and <a href="https://jqlang.github.io/jq/">jq</a>.</p><p>Before diving</p>]]></description><link>https://blog.badgerops.net/decoding-kubernetes-secrets-with-jq/</link><guid isPermaLink="false">677df43247f40d000177cf3f</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Wed, 08 Jan 2025 03:48:03 GMT</pubDate><content:encoded><![CDATA[<h1 id></h1><p>It&apos;s been a while since I&apos;ve posted, and I generally post about things I&apos;ve learned / have helped me in my day to day role. This is a quick blog post about easily decoding base64 encoded secrets in kubernetes, using Kubectl and <a href="https://jqlang.github.io/jq/">jq</a>.</p><p>Before diving into decoding Kubernetes secrets, let&apos;s set up a local development environment using <code>kind</code> (<a href="https://kind.sigs.k8s.io/">Kubernetes in Docker</a>). If you haven&apos;t used kind before, it&apos;s a fantastic tool for local Kubernetes development. Here&apos;s how to create a basic cluster:</p><p><code>kind create cluster</code></p><p>That&apos;s it! Once your cluster is ready (usually takes about a minute), you can verify it&apos;s working:</p><p><code>kubectl cluster-info --context kind-kind</code></p><p>Now, let&apos;s explore how to work with Kubernetes secrets. We&apos;ll create a simple secret and learn different ways to decode it.</p><p>First, let&apos;s create a secret with a few key-value pairs:</p><p><code>&#x276F; kubectl create secret generic decode-example \<br>  --from-literal=key1=value1 \<br>  --from-literal=key2=value2 \<br>--from-literal=key3=value3<br>secret/decode-example created</code></p><p>When we examine this secret using <code>kubectl get secret</code> with JSON output, we can see our values are base64 encoded:</p><p><code>&#x276F; kubectl get secret/decode-example -ojson | jq</code> <br><code>{<br>  &quot;apiVersion&quot;: &quot;v1&quot;</code>,<br><code>  &quot;data&quot;: {<br>    &quot;key1&quot;: &quot;dmFsdWUx&quot;</code>,<br><code>    &quot;key2&quot;: &quot;dmFsdWUy&quot;</code>,<br><code>    &quot;key3&quot;: &quot;dmFsdWUz&quot;<br>  }</code>,<br><code>  &quot;kind&quot;: &quot;Secret&quot;</code>,<br><code>  &quot;metadata&quot;: {<br>    &quot;creationTimestamp&quot;: &quot;2025-01-07T18:06:56Z&quot;</code>,<br><code>    &quot;name&quot;: &quot;decode-example&quot;</code>,<br><code>    &quot;namespace&quot;: &quot;default&quot;</code>,<br><code>    &quot;resourceVersion&quot;: &quot;674&quot;</code>,<br><code>    &quot;uid&quot;: &quot;d0369e58-089c-4ea3-8998-4c931b904ef2&quot;<br>  }</code>,<br><code>  &quot;type&quot;: &quot;Opaque&quot;<br>}</code></p><p>Here&apos;s where things get fun. We can use <code>jq</code> to decode these values in several ways, depending on what information we need:</p><p>For a simple list of decoded values:</p><p><code>&#x276F; kubectl get secret/decode-example -ojson | jq -r &apos;.data | to_entries | .[] | .value | @base64d&apos;<br>value1<br>value2<br>value3</code></p><p>If you need specific keys with their decoded values (notice we&apos;re only selecting <code>key1</code> and <code>key3</code>):</p><p><code>&#x276F; kubectl get secret/decode-example -ojson | jq &apos;{key1: .key1 | @base64d, key3: .key3 | @base64d}&apos;<br>{<br>  &quot;key1&quot;: &quot;value1&quot;</code>,<br><code>  &quot;key3&quot;: &quot;value3&quot;<br>}</code></p><p>But the ez-mode approach to get all keys and values decoded in a clean JSON format is to use <code>map_values()</code> as seen here:</p><p><code>&#x276F; kubectl get secret/decode-example -ojson | jq &apos;.data | map_values(@base64d)&apos;<br>{<br>  &quot;key1&quot;: &quot;value1&quot;</code>,<br><code>  &quot;key2&quot;: &quot;value2&quot;</code>,<br><code>  &quot;key3&quot;: &quot;value3&quot;<br>}</code></p><p>This last command is useful as it preserves the structure while giving us human-readable values, and reduces the labor intensive method of the previous example.</p><p>By leveraging <code>kubectl</code> and <code>jq</code> together, we can quickly decode and inspect Kubernetes secrets without needing additional tools or complex scripts. Pretty neat, right?</p><p>Don&apos;t forget to clean up your kind cluster when you&apos;re done:</p><p><code>kind delete cluster</code></p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[CVE's]]></title><description><![CDATA[<p>CVE&apos;s. Gamified? Maybe. Useful? Maybe. Fast-becoming-too-complex to manage? I think so.</p><p>But. This is <em>currently</em> one of the best ways of unified reporting &amp; alerting of vulnerabilities to a wide audience. There&apos;s certainly room for improvement.</p><p>Recently, I got an inside view on the process.</p><p>My</p>]]></description><link>https://blog.badgerops.net/cves/</link><guid isPermaLink="false">6626a309e181a20001778ad3</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Mon, 22 Apr 2024 18:13:35 GMT</pubDate><content:encoded><![CDATA[<p>CVE&apos;s. Gamified? Maybe. Useful? Maybe. Fast-becoming-too-complex to manage? I think so.</p><p>But. This is <em>currently</em> one of the best ways of unified reporting &amp; alerting of vulnerabilities to a wide audience. There&apos;s certainly room for improvement.</p><p>Recently, I got an inside view on the process.</p><p>My current <code>${DAY_JOB}</code> is heavily a Red Hat shop. We&apos;re using quite a few of their offerings, including <a href="https://github.com/quay/mirror-registry" rel="noreferrer">Mirror Registry </a>which is a packaged single-node <a href="https://www.projectquay.io/" rel="noreferrer">Quay </a>instance for disconnected environments container hosting.</p><blockquote>Is Quay pronounced &quot;Kee&quot; or Kway? Ancient scholars maintain the meaning was lost long ago...</blockquote><p>I was spinning up several Mirror Registry deployments across a few disconnected environments, when I realized something.</p><ol><li>They all had identical CSRF <code>SECRET_KEY</code> values</li><li>They all had literally <code>password</code> for Postgres and Redis (hey, I used <em>literally</em> correctly!)</li><li>They all had identical Database <code>SECRET_KEY</code> values</li></ol><p>This... is not optimal.</p><p>So. I did 2 things. Well, 3. But lets talk about the first two.</p><ol><li>I emailed Red Hat Security <code>secalert@redhat.com</code> per their <a href="https://access.redhat.com/security/team/contact/" rel="noreferrer">documented procedures</a> on 23 Feb 2024</li><li>I <a href="https://github.com/quay/mirror-registry/pull/142" rel="noreferrer">prepared a PR </a> to resolve the issue, and submitted after coordinating with Red Hat Security.</li></ol><p>The initial email exchange went smoothly and rapidly, then.... Things languished. No response for 1.5 months, and I finally... well, I finally resorted to the method everybody who gets fed up with waiting. I <a href="https://x.com/Badgerops/status/1778000836871766232" rel="noreferrer">tweeted angrily</a> and wouldn&apos;t ya know, 2 hours later we had forward progress.</p><p>And, surprisingly, to me at least - they issued 4 CVE&apos;s for these reported issues.</p><ul><li>CVE-2024-3622</li><li>CVE-2024-3623</li><li>CVE-2024-3624</li><li>CVE-2024-3625</li></ul><p>To be honest, I wasn&apos;t looking for CVE&apos;s, I&apos;m not a bug bounty enthusiast (and it doesn&apos;t look like Red Hat even participates in any!) I&apos;m just a server wrangler who doesn&apos;t like hard-coded passwords.</p><p>So, it was fun to contribute back to a project that has provided value.</p><p>It was cool to be on the <em>submitting</em> end of a security report (for once)</p><p>And then, uh... Well, I did say 3 things, right? So:</p><p> 3: I helpfully included <a href="https://issues.redhat.com//browse/PROJQUAY-7001" rel="noreferrer">a bug</a> so .... <a href="https://github.com/quay/mirror-registry/pull/151" rel="noreferrer">another PR</a> to fix that issue.</p><p>At the end of the day, some people love CVE&apos;s, some people hate them and some just like calculating CVSS&apos;s <em>way too much</em>. They&apos;re a tool, a method for cataloguing and communicating Security vulnerabilities in a (mostly) consistent manner.</p><p>Use the tools we have available, and patch those bugs!</p><p>(And please, <em>please </em>don&apos;t hard-code creds...)</p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[Dachau]]></title><description><![CDATA[<p>Last weekend, we traveled to the Alpine town of <a href="https://en.wikipedia.org/wiki/Garmisch-Partenkirchen" rel="noreferrer">Garmisch</a>, explored the beautiful <a href="https://www.neuschwanstein.de/" rel="noreferrer">Neuschwanstein Castle</a>, and on our return trip to Stuttgart, visited the <a href="https://www.dachau.de/en/tourism/concentration-camp-memorial-site.html" rel="noreferrer">Dachau</a> memorial.</p><p>Words cannot express what we experienced while walking through the memorial site.</p><p>The utter disregard for humanity.</p><p>The engineered, <em>optimized</em> methodology for corralling, oppressing,</p>]]></description><link>https://blog.badgerops.net/dachau/</link><guid isPermaLink="false">65f992a8e181a20001778a23</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Tue, 19 Mar 2024 14:00:47 GMT</pubDate><content:encoded><![CDATA[<p>Last weekend, we traveled to the Alpine town of <a href="https://en.wikipedia.org/wiki/Garmisch-Partenkirchen" rel="noreferrer">Garmisch</a>, explored the beautiful <a href="https://www.neuschwanstein.de/" rel="noreferrer">Neuschwanstein Castle</a>, and on our return trip to Stuttgart, visited the <a href="https://www.dachau.de/en/tourism/concentration-camp-memorial-site.html" rel="noreferrer">Dachau</a> memorial.</p><p>Words cannot express what we experienced while walking through the memorial site.</p><p>The utter disregard for humanity.</p><p>The engineered, <em>optimized</em> methodology for corralling, oppressing, <em>destroying</em> an entire group of people was evident. This was not just thrown together.<br>How does this happen? In retrospect, it is easy to see the slow fade that rolled into a massive swing towards something so despicable it is impossible to dwell on it without a overwhelming sense of despair.</p><p>How do we stop from continuously repeating this type of behavior? Why do humans inevitably generate &quot;reasons&quot; to attack,  oppress, destroy others?</p><p>What can <em>I</em> do to help? Individually, not much - not on a global scale. Together? Still, not much. We&apos;re up against geopolitical structures that simply do not want or care to actually do the morally right things. It doesn&apos;t generate power or money - but maybe this is just me being jaded, cynical and using those excuses to decouple my emotions from my observations.</p><p>I don&apos;t have answers. But I am more thoughtful than I was yesterday, and with that maybe am better equipped to be a positive force in this world. Is the phrase &quot;Be the change you wish to see in the world&quot; over used? Maybe, again, from a cynical point of view.</p><p>Choose not to be cynical, choose not to be jaded. Choose to care.</p><p>So.</p><p>What am I going to do about this? I don&apos;t know. Focus more on being a better Husband, Father, Brother, Friend for one. Looking for opportunities to do something <em>outside myself</em> for other people.  I can&apos;t stop the bad, but I can spread good.</p><p>Be the change you wish to see in the world.</p>]]></content:encoded></item><item><title><![CDATA[Keycloak & Open Shift]]></title><description><![CDATA[<p></p><p>Hi there!</p><p>So. You&apos;re running Open Shift Container Platform 4.12+ and you&apos;re wanting to deploy that shiny new Red Hat Keycloak Operator (v22) and set up Oauth from Keycloak into Open Shift. </p><p>How do you deploy Keycloak as an IDP for Open Shift? The magic</p>]]></description><link>https://blog.badgerops.net/keycloak-open-shift/</link><guid isPermaLink="false">65be2b253ef7fe00012004d5</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Sat, 03 Feb 2024 15:06:07 GMT</pubDate><content:encoded><![CDATA[<p></p><p>Hi there!</p><p>So. You&apos;re running Open Shift Container Platform 4.12+ and you&apos;re wanting to deploy that shiny new Red Hat Keycloak Operator (v22) and set up Oauth from Keycloak into Open Shift. </p><p>How do you deploy Keycloak as an IDP for Open Shift? The magic words being &quot;Configure Keycloak as an IDP for Open Shift&quot; [hashtag seo]. </p><p>Well, let us talk about that.</p><p>Is it straightforward? Sort of.</p><p>Well documented? No.</p><p>Let&apos;s fix that.</p><p>First off, some assumptions:</p><ol><li>Lets assume you are paying for Red Hat and have access to the Operators<ol><li>I&apos;ll add some &quot;here&apos;s how to do it without operators&quot; options, but I&apos;m 99% sure, if you&apos;re running Open Shift, you&apos;re in the RH ecosystem.</li></ol></li><li>Lets assume you&apos;re deploying a standalone PostgreSQL DB, or cluster - I used the excellent <a href="https://docs.percona.com/percona-operator-for-postgresql/2.0/index.html" rel="noreferrer">Percona Operator for PostgreSQL</a>. <ol><li><em>NOTE</em>: The &quot;<a href="https://catalog.redhat.com/software/container-stacks/detail/5f6cf3ff8badb0cacbb48cd5" rel="noreferrer">Red Hat Certified</a>&quot; version of the operator is <code>2.3.1</code> as of this writing.</li></ol></li><li>Finally, I&apos;m going to assume that you know how to click &quot;install&quot; on the operator page, so I&apos;m not going to walk you through that step by step. </li><li>If you don&apos;t want all the preamble, skip down to the &quot;Configuring Oauth &amp; Groups&quot; section because that is what you&apos;re likely stuck on.</li></ol><hr><p>Create yourself a  namespace, er, I mean <em>project</em> - I used <code>keycloak</code> since I am a super creative individual. In that namespace, deploy your PostgreSQL DB in whichever manner you choose - I am using the above mentioned Percona Operator, and took all the defaults when deploying the PerconaPGCluster using the &quot;create PerconaPGCluster&quot; button.</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/pg-cluster-deploy.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/pg-cluster-deploy.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/pg-cluster-deploy.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/pg-cluster-deploy.png 1600w, https://blog.badgerops.net/content/images/2024/02/pg-cluster-deploy.png 2168w" sizes="(min-width: 720px) 720px"></figure><p>Go grab a cup of coffee, tea or $beverage, it&apos;ll take a few minutes for everything to deploy.</p><blockquote>If you&apos;re not into the operator/PostgreSQL cluster thing, then you can just deploy an ephemeral PostgreSQL DB following the <a href="https://www.keycloak.org/operator/basic-deployment" rel="noreferrer">Keycloak.org guide here</a> </blockquote><p>Next, install the <a href="https://access.redhat.com/documentation/en-us/red_hat_build_of_keycloak/22.0/html-single/operator_guide/index" rel="noreferrer">Red Hat build of Keycloak v22</a> operator into the namespace.</p><p>After that install is complete, we&apos;ll deploy the Keycloak instance.</p><blockquote>Note: as of the time of this writing the operator default yaml is incorrectly formatted. I pushed a bugfix to the upstream github repo to fix the structure, but it hasn&apos;t made it down to the Red Hat build yet.</blockquote><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/wrong-example-keycloak.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/wrong-example-keycloak.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/wrong-example-keycloak.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/wrong-example-keycloak.png 1600w, https://blog.badgerops.net/content/images/2024/02/wrong-example-keycloak.png 2168w" sizes="(min-width: 720px) 720px"></figure><p>Wrong structure:</p><pre><code class="language-yaml">kind: Keycloak
apiVersion: k8s.keycloak.org/v2alpha1
metadata:
  name: example-keycloak
  labels:
    app: sso
  namespace: keycloak
spec:
  instances: 1
  hostname: example.org
  tlsSecret: my-tls-secret</code></pre><p>Correct structure:</p><pre><code class="language-yaml">kind: Keycloak
apiVersion: k8s.keycloak.org/v2alpha1
metadata:
  name: example-keycloak
  labels:
    app: sso
  namespace: keycloak
spec:
  instances: 1
  hostname: 
    hostname: example.org
  http:
    tlsSecret: my-tls-secret</code></pre><p>Both the <code>hostname</code> and <code>tlsSecret</code> blocks are incorrectly formatted, which will result in a failed Keycloak instance deployment.</p><p>Here is a screenshot of correctly configured yaml for my Keycloak deployment:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/keycloak-deployment-yaml.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/keycloak-deployment-yaml.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/keycloak-deployment-yaml.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/keycloak-deployment-yaml.png 1600w, https://blog.badgerops.net/content/images/2024/02/keycloak-deployment-yaml.png 2168w" sizes="(min-width: 720px) 720px"></figure><p>Now, this is <em>actually</em> an incorrect deployment! I&apos;m going to dig into that in another blog post since it is a separate issue, but re-using the <code>*.apps.&lt;cluster&gt;.&lt;domain&gt;</code> certificate and ingress will result in a weird issue where sometimes <code>console.apps.&lt;cluster&gt;.&lt;domain&gt;</code> traffic will get sent to the Keycloak service/pod due to <a href="https://datatracker.ietf.org/doc/html/rfc7540/#section-9.1.1" rel="noreferrer">http/2 connection reus</a>e getting confused with the console route and the Keycloak route. </p><p>Also, as annotated in <a href="https://docs.openshift.com/container-platform/4.12/networking/ingress-operator.html#nw-http2-haproxy_configuring-ingress">https://docs.openshift.com/container-platform/4.12/networking/ingress-operator.html#nw-http2-haproxy_configuring-ingress</a> we see the &apos;correct&apos; workaround is to have a completely separate certificate used:</p><blockquote>To enable the use of HTTP/2 for the connection from the client to HAProxy, a route must specify a custom certificate. A route that uses the default certificate cannot use HTTP/2. This restriction is necessary to avoid problems from connection coalescing, where the client re-uses a connection for different routes that use the same certificate.</blockquote><p>That said, it is not immediately obvious where http/2 is set to be default for Open Shift 4.12, or the Keycloak ingress itself. :shrug: this one took quite a while to track down and figure out, since the symptom was traffic randomly getting sent to the wrong pod (Keycloak). </p><p>Ok, moving on.</p><p>Now that your Keycloak instance is deployed into your namespace, grab the password:</p><pre><code class="language-bash">&#x276F; oc -n keycloak get secret example-keycloak-initial-admin -o jsonpath=&apos;{.data.username}&apos; | base64 --decode ; echo

admin    

&#x276F; oc -n keycloak get secret example-keycloak-initial-admin -o jsonpath=&apos;{.data.password}&apos; | base64 --decode ; echo

&lt;redacted&gt;                                  
</code></pre><p>If you&apos;re not familiar with Keycloak - it can be quite complicated to get going initially; which is the point of this blog post - then you&apos;ll want to refer to the Keycloak Server Admin docs, specifically the <a href="https://www.keycloak.org/docs/latest/server_admin/#configuring-realms" rel="noreferrer">Realm creation/configuration section</a>. You don&apos;t want to use the <code>master</code> Realm for your internal apps. I created a new realm called <code>badgerlab</code> to create my Open Shift client.</p><p>Now, create a new client in that new realm:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/kc-client-create1.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/kc-client-create1.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/kc-client-create1.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/kc-client-create1.png 1600w, https://blog.badgerops.net/content/images/2024/02/kc-client-create1.png 2168w" sizes="(min-width: 720px) 720px"></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.badgerops.net/content/images/2024/02/kc-client-create2.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/kc-client-create2.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/kc-client-create2.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/kc-client-create2.png 1600w, https://blog.badgerops.net/content/images/2024/02/kc-client-create2.png 2168w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Turn on Client Authentication!</span></figcaption></figure><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://blog.badgerops.net/content/images/2024/02/kc-client-create3.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/kc-client-create3.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/kc-client-create3.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/kc-client-create3.png 1600w, https://blog.badgerops.net/content/images/2024/02/kc-client-create3.png 2168w" sizes="(min-width: 720px) 720px"><figcaption><span style="white-space: pre-wrap;">Set a redirect URI, and Web Origin</span></figcaption></figure><blockquote>One huge piece of confusion that I had was on what redirect URI&apos;s would be required for this - again, something that is not well documented and I am unfortunately not very familiar with.</blockquote><p><a href="https://oauth-openshift.apps.badgerlab.badgerops.foo/oauth2callback/*"><code>https://oauth-openshift.apps.&lt;cluster&gt;.&lt;domain&gt;/oauth2callback/*</code></a></p><p>Thats it. That&apos;s the one redirect URI you need.</p><p>The Web Origin URL should be <a href="https://oauth-openshift.apps.badgerlab.badgerops.foo"><code>https://oauth-openshift.apps.</code></a><code>&lt;cluster&gt;.&lt;domain&gt;</code></p><p>Now that your client is created you can create users and groups! For clarity, I&apos;m creating a single user and a single group.</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/groups.png" class="kg-image" alt loading="lazy" width="2000" height="1323" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/groups.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/groups.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/groups.png 1600w, https://blog.badgerops.net/content/images/2024/02/groups.png 2168w" sizes="(min-width: 720px) 720px"></figure><p>Cool, we&apos;re all set on the Keycloak side now! (Ok, mostly - that is a teeny white lie, but lets let it play out)</p><p>Setup on the Open Shift side is more straightforward, we can <a href="https://docs.openshift.com/container-platform/4.12/authentication/configuring-oauth-clients.html" rel="noreferrer">follow along with the docs</a> to configure what we created on the Keycloak side.</p><p>There are a couple of ways to configure an oauth provider on the Open Shift side, but if you&apos;re logged in as kubeadmin you&apos;ll have a nice blue banner with a convenient hyperlink to click</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/oauth-dropdown.png" class="kg-image" alt loading="lazy" width="2000" height="1310" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/oauth-dropdown.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/oauth-dropdown.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/oauth-dropdown.png 1600w, https://blog.badgerops.net/content/images/2024/02/oauth-dropdown.png 2184w" sizes="(min-width: 720px) 720px"></figure><p>Or, you can go to Administration -&gt; Cluster Settings -&gt; Configuration and search for &apos;Oauth&apos;</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/oauth-1.png" class="kg-image" alt loading="lazy" width="2000" height="1310" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/oauth-1.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/oauth-1.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/oauth-1.png 1600w, https://blog.badgerops.net/content/images/2024/02/oauth-1.png 2184w" sizes="(min-width: 720px) 720px"></figure><p>Either way, add a new OpenID connect Identity Provider (IDP)</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/oauth-idp.png" class="kg-image" alt loading="lazy" width="2000" height="1319" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/oauth-idp.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/oauth-idp.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/oauth-idp.png 1600w, https://blog.badgerops.net/content/images/2024/02/oauth-idp.png 2175w" sizes="(min-width: 720px) 720px"></figure><p>Optionally, you can create a secret in the <code>openshift-config</code> namespace, with <code>clientSecret</code> as the key, and your client secret from Keycloak as the value, then use the following yaml structure to manually create an Oauth config:</p><pre><code class="language-yaml">spec:
  identityProviders:
    - mappingMethod: claim
      name: openid (whatever name you choose!)
      openID:
        claims:
          email:
            - email
          groups:
            - groups
          name:
            - name
          preferredUsername:
            - preferred_username
        clientID: openshift
        clientSecret:
          name: &lt;secret you created&gt;
        extraScopes: []
        issuer: &apos;https://keycloak.apps.&lt;cluster&gt;.&lt;domain&gt;/realms/&lt;your realm&gt;&apos;
      type: OpenID</code></pre><p>Note: the issuer is just the URL to your Keycloak realm - you can easily find it by going to realm settings in Keycloak, then clicking the &apos;OpenID endpoint configuration&apos; hyperlink, that will return the full <code>.well-known/openid-configuration</code> url that Open Shift needs. You&apos;ll need to just remove the <code>.well-known/openid-configuration</code> suffix and use the rest of the url with no trailing slash.</p><p>Ok, now that is all configured, you should be able to either log out of kubeadmin, or open a incognito tab/new browser window (recommended - keep your kubeadmin session open!) to test this out!</p><p>Going back to the console URL you should now see a new login button for openid, or whatever you named it:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/login-screen.png" class="kg-image" alt loading="lazy" width="2000" height="1319" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/login-screen.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/login-screen.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/login-screen.png 1600w, https://blog.badgerops.net/content/images/2024/02/login-screen.png 2167w" sizes="(min-width: 720px) 720px"></figure><p>Lets select openid:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/login-keycloak.png" class="kg-image" alt loading="lazy" width="2000" height="1325" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/login-keycloak.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/login-keycloak.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/login-keycloak.png 1600w, https://blog.badgerops.net/content/images/2024/02/login-keycloak.png 2176w" sizes="(min-width: 720px) 720px"></figure><p>This will be your Keycloak page loading, with the realm name you set, and any theme you want to set on Keycloak visible here</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/sorry-bout-that.png" class="kg-image" alt loading="lazy" width="2000" height="1325" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/sorry-bout-that.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/sorry-bout-that.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/sorry-bout-that.png 1600w, https://blog.badgerops.net/content/images/2024/02/sorry-bout-that.png 2176w" sizes="(min-width: 720px) 720px"></figure><p>Oh no?!? What the heck is this? You <em>may or may not</em> see this screen. I&apos;ll explain in a minute, just do a hard refresh ( ctrl + shift + r ) once or twice and it should load the console</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/logged-in.png" class="kg-image" alt loading="lazy" width="2000" height="1315" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/logged-in.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/logged-in.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/logged-in.png 1600w, https://blog.badgerops.net/content/images/2024/02/logged-in.png 2175w" sizes="(min-width: 720px) 720px"></figure><p>As an <em>authenticated</em> but not <em>authorized </em>user, you won&apos;t see anything in the cluster, but if we swap back to our kubeadmin session, we&apos;ll see a new user:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/user-synced.png" class="kg-image" alt loading="lazy" width="2000" height="1313" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/user-synced.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/user-synced.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/user-synced.png 1600w, https://blog.badgerops.net/content/images/2024/02/user-synced.png 2175w" sizes="(min-width: 720px) 720px"></figure><p>Note the identities section where we see <code>openid:&lt;guid&gt;</code> for the user, and the name, fullname, etc are sync&apos;d over from Keycloak.</p><p>Ok, we&apos;re on the right track. Now, how do we give the user access to <em>do stuff?</em></p><p>Here is where we will use the <a href="https://docs.openshift.com/container-platform/4.12/authentication/using-rbac.html" rel="noreferrer">RBAC docs</a> to map the group we created in Keycloak to a Role in Open Shift.</p><p>But first, where <em>is</em> that group? We know we see the user sync&apos;d over from Keycloak, but I would expect the group I created and added to my user to also sync over, but alas, there is nothing there!</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/nogroup.png" class="kg-image" alt loading="lazy" width="2000" height="1321" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/nogroup.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/nogroup.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/nogroup.png 1600w, https://blog.badgerops.net/content/images/2024/02/nogroup.png 2172w" sizes="(min-width: 720px) 720px"></figure><p>This one hung me up and had me <em>very</em> frustrated for a while until I stumbled across <a href="https://stackoverflow.com/questions/76919561/group-is-not-coming-in-jwt-token-in-keycloak-23-0-0" rel="noreferrer">this stackoverflow post</a>. The stackoverflow user <a href="https://stackoverflow.com/users/8054998/bench-vue" rel="noreferrer">Bench Vue</a> provided a wonderful step-by-step sequence of screenshots to ensure that Keycloak adds the user groups into the Java Web Token (JWT) that is sent to the client, which in our case is Open Shift.</p><p>Heading back to our Keycloak page, we navigate to:</p><ul><li>our Realm we created</li><li>Client Scopes</li><li>Profile</li><li>&quot;Add Mapper&quot; </li><li>&quot;Group Membership&quot;</li><li>set name to <code>user_group_in_jwt</code><ul><li>UNSELECT &apos;full group path&apos; or else the group will have a preceding <code>/</code> which makes Open Shift error out very badly</li></ul></li></ul><p>Stackoverflow article screenshot here for posterity:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/stackoverflow-screenshot.png" class="kg-image" alt loading="lazy" width="2000" height="815" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/stackoverflow-screenshot.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/stackoverflow-screenshot.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/stackoverflow-screenshot.png 1600w, https://blog.badgerops.net/content/images/size/w2400/2024/02/stackoverflow-screenshot.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>With this added, log out of your user session in Open Shift, and log back in. Now you should be able to check to see the group is sync&apos;d over in your kubeadmin Open Shift session:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/groups_added.png" class="kg-image" alt loading="lazy" width="2000" height="1316" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/groups_added.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/groups_added.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/groups_added.png 1600w, https://blog.badgerops.net/content/images/2024/02/groups_added.png 2176w" sizes="(min-width: 720px) 720px"></figure><p>So, we can click into that group that is synced over from Keycloak, swap to the &apos;rolebindings&apos; tab and click &quot;create binding&quot;. We&apos;ll make this a <em>cluster</em> rolebinding, and give ourselves the <code>cluster-admin</code> role:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/rolebinding.png" class="kg-image" alt loading="lazy" width="2000" height="1315" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/rolebinding.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/rolebinding.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/rolebinding.png 1600w, https://blog.badgerops.net/content/images/2024/02/rolebinding.png 2185w" sizes="(min-width: 720px) 720px"></figure><p>Once we click &apos;create&apos; we can swap back to our user session and refresh, we should see that we&apos;re now cluster admin&apos;s with the ability to see everything:</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/clusteradmin.png" class="kg-image" alt loading="lazy" width="2000" height="1470" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/clusteradmin.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/clusteradmin.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/clusteradmin.png 1600w, https://blog.badgerops.net/content/images/2024/02/clusteradmin.png 2244w" sizes="(min-width: 720px) 720px"></figure><p>Note, this is a small 3 node cluster running on some old laptops. It is wildly underpowered and Open Shift likes to complain about that, ha!</p><p>So now at ~1,600 words in I&apos;m about ready to wrap it up.</p><p><br>But Wait! What about that weird &quot;We are sorry...&quot; screen that came up? </p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/02/sorry-bout-that-1.png" class="kg-image" alt loading="lazy" width="2000" height="1325" srcset="https://blog.badgerops.net/content/images/size/w600/2024/02/sorry-bout-that-1.png 600w, https://blog.badgerops.net/content/images/size/w1000/2024/02/sorry-bout-that-1.png 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/02/sorry-bout-that-1.png 1600w, https://blog.badgerops.net/content/images/2024/02/sorry-bout-that-1.png 2176w" sizes="(min-width: 720px) 720px"></figure><p>Remember 25 minutes when I was saying something about http/2 connection reuse? And that I re-used the <code>*.apps.&lt;cluster&gt;.&lt;domain&gt;</code> certificate for this Keycloak deployment?</p><p>As it turns out, that causes a weird problem where Open Shift SDN will sometimes, but not always, randomly send traffic meant for the Open Shift Console to the Keycloak pod, which quite correctly states &quot;I don&apos;t know what to do with this? Sorry?&quot;</p><p>There are a couple &apos;correct&apos; fixes here. We can set <code>tls: reencrypt</code> on the ingress, or create a new certificate <code>keycloak.&lt;whatever&gt;</code> and set our ingress to use that instead of re-using the same  <code>*.apps.&lt;cluster&gt;.&lt;domain&gt;</code> certificate. </p><blockquote>Which frankly, is a little annoying since the whole Open Shift &quot;Hey, everything lives under the  <code>*.apps.&lt;cluster&gt;.&lt;domain&gt;</code>  path and &quot;just works&quot; - uh, that is, until it doesn&apos;t.</blockquote><p>What I ended up doing in our Production environment was creating a new certificate and using that, along with a slightly different Keycloak DNS path <code>keycloak.&lt;region&gt;.&lt;domain&gt;</code> - and set an &apos;A&apos; record pointing to the same IP that  <code>*.apps.&lt;cluster&gt;.&lt;domain&gt;</code> used for that Keycloak DNS entry. That works.</p><p>So that&apos;s it, that&apos;s the blog post. I hope that it helps <em>you</em> figure out how to deploy Keycloak as an IDP for Open Shift using Oauth, and setting up RBAC mapping for your Keycloak Group(s) to Open Shift Roles. I may write a more in depth blog post on that in the future as I learn more in that area.</p><p>As always, feel free to hit me up on twitter @badgerops or mastodon.social/@badgerops if you have pointers, questions or corrections.</p><p>Cheers!</p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[smtp socket: malformed response on a FIPS 140-2 system]]></title><description><![CDATA[<p>Ok, this is a very highly specific post - but I hope it is useful for that sysadmin who&apos;s tearing their hair out trying to figure out wtf is going on with smtp failing with a vague error message.</p><p>Recently, I was configuring a Postfix SMTP relay on</p>]]></description><link>https://blog.badgerops.net/smtp-socket-malformed-response-on-a-fips140-2-sytstem/</link><guid isPermaLink="false">659982f30e5f400001eb6a19</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Sat, 06 Jan 2024 16:56:24 GMT</pubDate><content:encoded><![CDATA[<p>Ok, this is a very highly specific post - but I hope it is useful for that sysadmin who&apos;s tearing their hair out trying to figure out wtf is going on with smtp failing with a vague error message.</p><p>Recently, I was configuring a Postfix SMTP relay on a FIPS140-2 enabled system, and had a weird error that I hadn&apos;t ever seen before:</p><pre><code class="language-bash">warning: private/smtp socket: malformed response
warning: transport smtp failure -- see a previous warning/fatal/panic logfile record for the problem description
warning: process /usr/libexec/postfix/smtp pid  killed by signal 11
warning: /usr/libexec/postfix/smtp: bad command startup -- throttling
</code></pre>
<p>The <code>warning: private/smtp socket: malformed response</code> line is specifically what the error was. </p><p>Googling this issue turns up mostly chrooted postfix issues, or incorrect permissions on the <code>/etc/services</code> file. Not a lot of useful information for my specific issue!</p><p>In <a href="https://access.redhat.com/solutions/6958957" rel="noreferrer">this Red Hat Knowledgebase article</a> I finally found the correct answer! Now, it&apos;s obviously paywalled behind a Red Hat subscription, however knowing the magic string to search for turns up this <a href="https://unix.stackexchange.com/questions/440665/postfix-configuration-issue-with-fips-on-centos-7-mailgun-relay" rel="noreferrer">stackoverflow article</a> and we see that converting the hashing function from <code>md5</code> which is disabled on a FIPS 140-2 enabled system to <code>sha256</code> by running the following commands:</p><pre><code class="language-bash"># postconf -e smtp_tls_fingerprint_digest=sha256
# postconf -e smtpd_tls_fingerprint_digest=sha256
# systemctl restart postfix
</code></pre>
<p>You can also just add the two lines to your <code>/etc/postfix/main.cf</code> file, whatever floats your boat. It would be <em>super</em> if they were in that file commented out, but they&apos;re not (at least not on RHEL 8.x)</p><p>That&apos;s it, that&apos;s the blog post. Go forth and send emails.</p><p>-BadgerOps</p>]]></content:encoded></item><item><title><![CDATA[Germany]]></title><description><![CDATA[<p>Those of you who&apos;ve been following my sporadic social media postings will know that the BadgerFam moved to Germany last summer.</p><p>I&apos;m doing some work for some people that involves a lot of yaml, stiggin&apos;, bare metal -&gt; openshift, fips, fapolicyd, selinux and much</p>]]></description><link>https://blog.badgerops.net/germany/</link><guid isPermaLink="false">6599152d0e5f400001eb69a5</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Sat, 06 Jan 2024 10:07:21 GMT</pubDate><content:encoded><![CDATA[<p>Those of you who&apos;ve been following my sporadic social media postings will know that the BadgerFam moved to Germany last summer.</p><p>I&apos;m doing some work for some people that involves a lot of yaml, stiggin&apos;, bare metal -&gt; openshift, fips, fapolicyd, selinux and much more <em>fun. </em>I&apos;ll let you draw your conclusions on the specifics.</p><p>Why Germany? Well, Mrs Badger and I have been talking about an extended EU trip for quite a few years now. We had initially planned on renting a flat for ~3 mo one summer, working remote, and exploring Europe. Germany is a pretty central place to be able to quickly reach other countries, plus <em>I like trains.</em> </p><blockquote>(sadly, the train system is currently a mess - lots of strikes, disruptions in service, delayed trains all the time)</blockquote><p>But no, really, why Germany? Well, an interesting opportunity presented itself in Stuttgart, and the company I&apos;m working with was pretty generous with resources - allowing us to move over here, live, send our kids to school and more. I&apos;m pretty happy with this project, it&apos;s challenging - but I&apos;ve been lucky to have some very good experiences in my past that set me up well to help solve the problems they&apos;re facing. </p><p>The spawn have settled into school, spawn0 is heavily involved with Drill &amp; Ceremony teams - learning to spin rifles and do precision movements. <br>Spawn1 is involved in a local group play (Schoolhouse Rock!) and is enjoying that experience. (mostly - its <em>a lot</em> of practice)</p><p>What have we been doing outside of work and school?</p><h2 id="exploring"><em>Exploring</em>!</h2><h2 id></h2><h3 id="castles">Castles!</h3><figure class="kg-card kg-gallery-card kg-width-wide kg-card-hascaption"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_6662.jpeg" width="1536" height="2048" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_6662.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_6662.jpeg 1000w, https://blog.badgerops.net/content/images/2024/01/IMG_6662.jpeg 1536w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_6682.jpeg" width="1536" height="2048" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_6682.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_6682.jpeg 1000w, https://blog.badgerops.net/content/images/2024/01/IMG_6682.jpeg 1536w" sizes="(min-width: 720px) 720px"></div></div><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7500.jpeg" width="2000" height="1386" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7500.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7500.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_7500.jpeg 1600w, https://blog.badgerops.net/content/images/2024/01/IMG_7500.jpeg 2130w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7521.jpeg" width="2000" height="1500" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7521.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7521.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_7521.jpeg 1600w, https://blog.badgerops.net/content/images/2024/01/IMG_7521.jpeg 2048w" sizes="(min-width: 720px) 720px"></div></div></div><figcaption><p><span style="white-space: pre-wrap;">Several photos of castles in our travels. The sharp eyed will notice the bottom two are in London. I am bad at taking photos, too busy taking it in!</span></p></figcaption></figure><p>We&apos;ve seen a number of really cool castles, and taken <em>very few</em> photos of them. Been really bad at taking photos along the way. </p><p>Being able to see the evolution of building styles across regions and centuries is quite fascinating. Many of the castles are well maintained to this day, and are cheap or free to enter.</p><h3 id="kirches">Kirches!</h3><p></p><figure class="kg-card kg-gallery-card kg-width-wide kg-card-hascaption"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_6955.jpeg" width="1536" height="2048" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_6955.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_6955.jpeg 1000w, https://blog.badgerops.net/content/images/2024/01/IMG_6955.jpeg 1536w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_6996.jpeg" width="1536" height="2048" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_6996.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_6996.jpeg 1000w, https://blog.badgerops.net/content/images/2024/01/IMG_6996.jpeg 1536w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7125.jpeg" width="2000" height="1500" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7125.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7125.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_7125.jpeg 1600w, https://blog.badgerops.net/content/images/2024/01/IMG_7125.jpeg 2048w" sizes="(min-width: 720px) 720px"></div></div><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7650.jpeg" width="2000" height="1500" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7650.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7650.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_7650.jpeg 1600w, https://blog.badgerops.net/content/images/2024/01/IMG_7650.jpeg 2048w" sizes="(min-width: 720px) 720px"></div><div class="kg-gallery-image"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7652.jpeg" width="2000" height="1500" loading="lazy" alt srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7652.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7652.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_7652.jpeg 1600w, https://blog.badgerops.net/content/images/2024/01/IMG_7652.jpeg 2048w" sizes="(min-width: 720px) 720px"></div></div></div><figcaption><p><span style="white-space: pre-wrap;">A variety of Churches (Kirches) we&apos;ve seen in our travels.</span></p></figcaption></figure><p>Check out this photosphere of a very well preserved Kirche in Mainz, Germany</p>
<!--kg-card-begin: html-->
<iframe src="https://www.google.com/maps/embed?pb=!4v1704528389391!6m8!1m7!1sCAoSLEFGMVFpcE9pbG1zVkdhQ1E1SGlEd2Q3WEhzdVBhTjdyVnJWOUlfakotamN2!2m2!1d49.9971531!2d8.274796199999999!3f36.45773440488722!4f47.83295647712231!5f0.7820865974627469" width="600" height="450" style="border:0;" allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
<!--kg-card-end: html-->
<h3 id="theme-parks">Theme parks?</h3><p>We went to the Deutchland Legoland and <em>dominated</em> the Firefighting competition. If you haven&apos;t had the joy of this competition, it is a group based challenge where you pile into a &quot;fire engine&quot; that is pump/piston powered. You have to move it about 100&apos; to the other side of the play area, where there is a pump powered water cannon and &quot;fire&quot; in the windows of a fake building. Once a sufficient amount of water has been pumped through the window, the &quot;fire&quot; drops out of view signalling it is time to pile back in and book it back across the play area. I&apos;m proud to say, we made it back to the start line before the next group even finished putting out their fire. The kids were less impressed than Mrs Badger and I, for some strange reason...</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_7037.jpeg" class="kg-image" alt loading="lazy" width="1536" height="2048" srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_7037.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_7037.jpeg 1000w, https://blog.badgerops.net/content/images/2024/01/IMG_7037.jpeg 1536w" sizes="(min-width: 720px) 720px"></figure><p>We also were able to make it down to Disneyland Paris, which was a really cool experience. It felt just like Disneyland &quot;back home&quot;. The rides were all significantly more intense than California or Florida. I&apos;m a fan!</p><h3 id="whats-next">Whats next?</h3><p>We are here through ~June of 2024, and will be heading back to the 208 at that time. We&apos;re enjoying the experience here, but we miss our friends, family, camping, tacos, motorcycles and especially <em>Mac</em> - the worlds greatest dog.</p><figure class="kg-card kg-image-card"><img src="https://blog.badgerops.net/content/images/2024/01/IMG_5128.jpeg" class="kg-image" alt loading="lazy" width="2000" height="1500" srcset="https://blog.badgerops.net/content/images/size/w600/2024/01/IMG_5128.jpeg 600w, https://blog.badgerops.net/content/images/size/w1000/2024/01/IMG_5128.jpeg 1000w, https://blog.badgerops.net/content/images/size/w1600/2024/01/IMG_5128.jpeg 1600w, https://blog.badgerops.net/content/images/size/w2400/2024/01/IMG_5128.jpeg 2400w" sizes="(min-width: 720px) 720px"></figure><p>There are so many things we still want to see while we&apos;re over here, I&apos;ll do my best to write a little more about them.</p><p>Cheers!</p><p>-Badger</p>]]></content:encoded></item><item><title><![CDATA[Wigle wardrive from Idaho -> D3F C0N]]></title><description><![CDATA[<p>Long time, no post! </p><p>Driving down from Idaho to Las Vegas for Black Hat, BSidesLV and D3F C0N. Figured, hey, what the heck, let&apos;s war drive!</p><p>I grabbed my trusty old Alfa USB WiFi card, Garmin eTrex Legend &amp; serial adaptor and a unused Raspberry Pi from the</p>]]></description><link>https://blog.badgerops.net/wigle-wardrive-from-idaho-d3f-c0n/</link><guid isPermaLink="false">653505aeed6b250001793aac</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Sun, 07 Aug 2022 18:55:54 GMT</pubDate><content:encoded><![CDATA[<p>Long time, no post! </p><p>Driving down from Idaho to Las Vegas for Black Hat, BSidesLV and D3F C0N. Figured, hey, what the heck, let&apos;s war drive!</p><p>I grabbed my trusty old Alfa USB WiFi card, Garmin eTrex Legend &amp; serial adaptor and a unused Raspberry Pi from the box-o-parts, installed Kismet and tossed it in the back of the car. Fingers crossed, I&apos;ll actually get some useful AP&apos;s scanned during this trip. Lazy-posting my wigle.net deets below, I&apos;ll update this post as we get more info...</p><p>Posting the script I used <a href="https://github.com/BadgerOps/random_scripts/blob/master/wigle_scanner_upload.sh">on my github</a> - it&apos;s pretty simple, but does the trick.</p><p></p><p>-BadgerOps</p><figure class="kg-card kg-image-card"><img src="https://wigle.net/bi/LqEI7EgpdXIA6CpMPQ8qmQ.png" class="kg-image" alt loading="lazy"></figure>]]></content:encoded></item><item><title><![CDATA[Export/Clone Linode VPS to AWS EC2]]></title><description><![CDATA[<p>Today we&apos;re looking at two methods of migrating a Linode Linux instance to an AWS EC2 instance. We can use <a href="https://www.linode.com/docs/platform/disk-images/copying-a-disk-image-over-ssh/">the official Linode disk copy guide</a> as a starting point, but that doesn&apos;t really get us all the way there, as we still need to <em>import</em></p>]]></description><link>https://blog.badgerops.net/clone-linode-vm-to-aws-ec2/</link><guid isPermaLink="false">653505aeed6b250001793aab</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Tue, 10 Mar 2020 12:28:15 GMT</pubDate><content:encoded><![CDATA[<p>Today we&apos;re looking at two methods of migrating a Linode Linux instance to an AWS EC2 instance. We can use <a href="https://www.linode.com/docs/platform/disk-images/copying-a-disk-image-over-ssh/">the official Linode disk copy guide</a> as a starting point, but that doesn&apos;t really get us all the way there, as we still need to <em>import</em> that image. This guide will walk you through the following:</p><h3 id="harder-but-more-flexible-process-if-you-can-t-create-a-second-disk-image-in-linode-based-on-instance-size-">Harder, but more flexible process if you can&apos;t create a second disk image in Linode (based on instance size)</h3><ul><li>Create disk image of your target Linode</li><li>Copy the disk image to S3 so we can use the AWS snapshot import tool</li><li>Import disk image to AWS as a disk snapshot</li><li>Create new EC2 instance</li><li>Write snapshot to EC2 volume</li><li>Update fstab, grub, network interface configuration on EC2 </li><li> <s>Profit</s> Reboot and use the new cloned image</li></ul><h3 id="easier-process-if-you-are-able-to-create-a-second-disk-in-your-linode-of-a-slightly-larger-size-than-your-main-disk">Easier process if you are able to create a second disk in your Linode of a slightly larger size than your main disk</h3><ul><li>Create disk image of your target Linode</li><li>Copy the disk image to S3 so we can use the AWS AMI import tool</li><li>Import disk image to AWS as an AMI</li><li>Create new EC2 from that AMI</li></ul><h5 id="please-read-through-both-sets-of-instructions-to-familiarize-yourself-with-the-process-then-follow-along-i-would-love-feedback-you-can-reach-me-badgerops-on-twitter-or-find-my-email-address-on-my-profile-and-reach-out-that-way-thank-you-">Please read through both sets of instructions to familiarize yourself with the process, then follow along! I would love feedback, you can reach me <code>@badgerops</code> on Twitter, or find my email address on my profile and reach out that way. Thank you!</h5><p>The first thing we&apos;ll do is ensure we have the needed pre-requisites, as well as <em>a written down process</em>, as there are a couple of ways to accomplish the import depending on the resources you have available.</p><p>1: You&apos;ll need AWS CLI credentials, or, the ability to create IAM roles &amp; policies from the AWS Console.</p><p>2: You&apos;ll need either enough disk space in your Linode to create an image of the disk, or a large enough EC2 Volume attached to an EC2 instance to copy the image to over SSH so you can then import the image.</p><p>3: A written procedure for what you&apos;re doing, don&apos;t just follow along with this post! Write down your steps and mark them off as you go so you don&apos;t do what I did and have to go back and do a step over again that you missed. Ask me how I came up with this pre-requisite.</p><h6 id="a-quick-note-on-linode-vps-disks-based-on-the-linode-size-you-have-chosen-and-the-way-you-configured-your-disks-initially-you-may-or-may-not-have-enough-disk-space-to-create-your-disk-image-in-linode-you-have-a-couple-options-">A quick note on Linode VPS disks: based on the Linode size you have chosen, and the way you configured your disks initially, you may or may not have enough disk space to create your disk image in Linode. You have a couple options:</h6><ul><li>Resize your Linode to be a bigger instance, choose the instance that will (at least) double your current storage size, so you can create an image</li><li>Copy the disk image over SSH as its being created to another EC2 instance (or your workstation) so you can import it from there. Your target EC2 or workstation will need to have enough disk space for the image you&apos;re creating.</li></ul><h4 id="now-on-to-the-guide-">Now, on to the guide:</h4><hr><h4 id="note-if-you-d-like-to-follow-along-with-the-aws-guide-for-importing-a-vm-image-snapshot-the-documentation-is-available-here">NOTE: If you&apos;d like to follow along with the AWS guide for importing a VM/Image/Snapshot, the documentation is available <a href="https://docs.aws.amazon.com/vm-import/latest/userguide/vmimport-image-import.html">here</a></h4><p></p><h3 id="create-s3-bucket">Create S3 bucket</h3><p>From the AWS Console, navigate to S3 and create a bucket, or identify an existing S3 bucket you&apos;d like to use to store the disk image. You could also use the AWS CLI tool to create your S3 bucket. Due to the way the AWS vm import tool works, we have to use an S3 bucket.</p><h3 id="create-iam-role-and-policy">Create IAM Role and Policy</h3><p></p><p>First we&apos;ll look at the role and policy you need to create regardless of whether you&apos;re using AWS CLI credentials, or if you only have access to the AWS Console. This policy allow you to read from the S3 bucket, and write to EC2 to create a snapshot.</p><ol><li>A role to allow you to import a VM (or, in our case a disk image)</li><li>A policy to allow your credentials, or EC2 instance to run the import. This will be assigned to the role.</li></ol><p>Here is the example role in json format:</p><!--kg-card-begin: markdown--><pre><code class="language-import-role.json">{
   &quot;Version&quot;: &quot;2012-10-17&quot;,
   &quot;Statement&quot;: [
      {
         &quot;Effect&quot;: &quot;Allow&quot;,
         &quot;Principal&quot;: { &quot;Service&quot;: &quot;vmie.amazonaws.com&quot; },
         &quot;Action&quot;: &quot;sts:AssumeRole&quot;,
         &quot;Condition&quot;: {
            &quot;StringEquals&quot;:{
               &quot;sts:Externalid&quot;: &quot;import-vm-role&quot;
            }
         }
      }
   ]
}</code></pre>
<!--kg-card-end: markdown--><p>Save this as <code>vm-import-role.json</code></p><p>You can <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user.html">create a new role in the AWS Console</a>, or run the following command from the AWS CLI:</p><!--kg-card-begin: markdown--><pre><code>aws iam create-role --role-name import-vm-role --assume-role-policy-document &quot;file:///path/to/import-vm-role.json&quot;
</code></pre>
<!--kg-card-end: markdown--><p>Next, we&apos;ll create the policy that allows us to read from the S3 bucket that we&apos;ll put the image in, and upload the image to EC2 as a snapshot:</p><h4 id="note-you-ll-need-to-insert-your-s3-bucket-name-where-i-have-s3_bucket_name-listed-in-the-resource-section">Note: you&apos;ll need to insert your S3 bucket name where I have &lt;s3_bucket_name&gt; listed in the resource section</h4><!--kg-card-begin: markdown--><pre><code>{
   &quot;Version&quot;:&quot;2012-10-17&quot;,
   &quot;Statement&quot;:[
      {
         &quot;Effect&quot;:&quot;Allow&quot;,
         &quot;Action&quot;:[
            &quot;s3:GetBucketLocation&quot;,
            &quot;s3:GetObject&quot;,
            &quot;s3:ListBucket&quot;
         ],
         &quot;Resource&quot;:[
            &quot;arn:aws:s3:::&lt;s3_bucket_name&gt;&quot;,
            &quot;arn:aws:s3:::&lt;s3_bucket_name&gt;/*&quot;
         ]
      },
      {
         &quot;Effect&quot;:&quot;Allow&quot;,
         &quot;Action&quot;:[
            &quot;s3:GetBucketLocation&quot;,
            &quot;s3:GetObject&quot;,
            &quot;s3:ListBucket&quot;,
            &quot;s3:PutObject&quot;,
            &quot;s3:GetBucketAcl&quot;
         ],
         &quot;Resource&quot;:[
            &quot;arn:aws:s3:::&lt;s3_bucket_name&gt;&quot;,
            &quot;arn:aws:s3:::&lt;s3_bucket_name&gt;/*&quot;
         ]
      },
      {
         &quot;Effect&quot;:&quot;Allow&quot;,
         &quot;Action&quot;:[
            &quot;ec2:ModifySnapshotAttribute&quot;,
            &quot;ec2:CopySnapshot&quot;,
            &quot;ec2:RegisterImage&quot;,
            &quot;ec2:Describe*&quot;
         ],
         &quot;Resource&quot;:&quot;*&quot;
      }
   ]
}

</code></pre>
<!--kg-card-end: markdown--><p>Save this as <code>vm-import-policy.json</code></p><p>Once again, you can <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create-console.html#access_policies_create-json-editor">use the AWS Console</a> to create the policy, or run the following command from the AWS CLI:</p><pre><code>aws iam put-role-policy --role-name import-vm-role --policy-name import-vm-policy --policy-document &quot;file:///path/to/vm-import-policy.json&quot;</code></pre><h3 id="prepare-linode-for-backup">Prepare Linode for backup</h3><p>At this point you&apos;ll want to have your plan in place for how you&apos;re planning on backing up your Linode, as we&apos;re going to shut the Linode down for the next few steps. </p><ul><li>Ensure everyone using your Linode knows you&apos;re shutting it down!</li><li>NOTE: if you have sensitive data and/or a database on this Linode, consider taking a backup before proceeding.</li><li>Reboot Linode to the Finnix <a href="https://www.linode.com/docs/troubleshooting/rescue-and-rebuild/">recovery environment</a> </li><li>Connect to your Linode using <a href="https://www.linode.com/docs/platform/manager/remote-access/#console-access">Lish</a></li></ul><p>If you&apos;ve decided to back up to a second disk on your Linode and import from there, skip the next section and go to the &quot;Create disk image to Linode second disk (simple/fast method)&quot; section. If you&apos;re copying your image over SSH to an existing EC2 instance, or your workstation (this is what I did) then keep reading.</p><h3 id="create-disk-image-from-linode-over-ssh-tunnel">Create disk image from Linode over SSH tunnel</h3><p>First create a (long!) root password and start the SSH service so we can connect to this Linode to create the image. You could also use ssh keys if you&apos;d prefer not to use password based authentication.</p><figure class="kg-card kg-code-card"><pre><code>passwd
service ssh start</code></pre><figcaption>Set a root password and start ssh</figcaption></figure><p>Next, from your <em>existing</em> EC2 instance, or workstation run the following command from a screen (or Tmux) session. (In case you lose connection to your EC2 instance, you don&apos;t want the backup command to fail)</p><pre><code>ssh root@&lt;linode_ip&gt; &quot;dd if=/dev/sda &quot; | dd of=/path/to/linode.img</code></pre><p>This command will use the Linux <a href="https://www.gnu.org/software/coreutils/manual/html_node/dd-invocation.html#dd-invocation">dd</a> utility to copy from your Linode to an image on your EC2 or workstation. </p><p>Depending on how large your Linode is, and how much bandwidth you have available to you, this could take a few hours. Once the command completes, move on to preparing and importing the image to S3.</p><h3 id="prepare-disk-image-for-import-to-aws-s3">Prepare disk image for import to AWS S3</h3><p><em>optional</em> do the following steps to shrink the disk image if you have a large amount of free space! In this example, I had a vastly overprovisioned Linode and wanted to reduce the size of the image before import.</p><pre><code class="language-bash"># first, verify the overall size of the image
du -h -s linode.img

1.3T linode.img

# create loop partition

losetup --find --partscan linode.img

# verify it got created and has the size we expect

lsblk | grep loop0
NAME          MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0           7:0    0  1.3T  0 loop

fdisk -l /dev/loop0
Disk /dev/loop0: 1.3 TiB, 1373856858112 bytes, 2683314176 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

# for paranoia&apos;s sake, run e2fsck

e2fsck -f /dev/loop0 

# mount the image for the next few steps

mkdir -p /mnt/linodeimg

mount /dev/loop0 /mnt/linodeimg

# then run fstrim on it, we can use fstrim to remove any blocks not used # by the filesystem as noted in the man page:
# &quot;fstrim is used on a mounted filesystem to discard (or &quot;trim&quot;) blocks # which are not in use by the filesystem.  This is useful for 
# solid-state drives (SSDs) and thinly-provisioned storage.

fstrim -v /mnt/linodeimg
/mnt/linodeimg: 1 TiB (1138564808704 bytes) trimmed (wow!)

# unmount the loop partition

umount /dev/loop0

# confirm the physical disk is resized

du -h -s linode.img
220G    linode.img

# now that the actual size is reduced, we&apos;ll also want to reduce 
# the fileystem size because it still thinks its 1.3T in size!

lsblk
NAME          MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0           7:0    0  1.3T  0 loop

resize2fs linode.img 250G

lsblk
NAME          MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0           7:0    0  250G  0 loop

# remove the loop device map

losetup -d /dev/loop0</code></pre><p>Now we copy the image into the S3 bucket that we&apos;ve prepared previously:</p><pre><code>aws s3 cp linode.img s3://&lt;your_s3_bucket_name&gt;/&lt;image_path&gt;
Completed 56.0 GiB/250.0 GiB (138.1 MiB/s) with 1 file(s) remaining</code></pre><p>Following along with <a href="https://docs.aws.amazon.com/vm-import/latest/userguide/vmie_prereqs.html">https://docs.aws.amazon.com/vm-import/latest/userguide/vmie_prereqs.html</a> we&apos;ll import the image using the policy and role we created previously.</p><p>Create a <code>containers.json</code> file with the following format:</p><pre><code class="language-json">[
  {
    &quot;Description&quot;: &quot;Linode Image&quot;,
    &quot;Format&quot;: &quot;raw&quot;,
    &quot;UserBucket&quot;: {
        &quot;S3Bucket&quot;: &quot;&lt;your_s3_bucket_name&gt;&quot;,
        &quot;S3Key&quot;: &quot;&lt;image_path&gt;/linode.img&quot;
    }
}]</code></pre><p>Now, import the disk as a snapshot:</p><pre><code>time aws ec2 import-snapshot --region us-west-2 --description &quot;Imported Linode Image&quot; --disk-container &quot;file:///containers.json&quot;
{
    &quot;SnapshotTaskDetail&quot;: {
        &quot;Status&quot;: &quot;active&quot;,
        &quot;Description&quot;: &quot;Linode Image&quot;,
        &quot;Format&quot;: &quot;RAW&quot;,
        &quot;DiskImageSize&quot;: 0.0,
        &quot;UserBucket&quot;: {
            &quot;S3Bucket&quot;: &quot;&lt;s3_bucket_name&gt;&quot;,
            &quot;S3Key&quot;: &quot;linode.img&quot;
        },
        &quot;Progress&quot;: &quot;3&quot;,
        &quot;StatusMessage&quot;: &quot;pending&quot;
    },
    &quot;Description&quot;: &quot;Linode Image&quot;,
    &quot;ImportTaskId&quot;: &quot;import-snap-&lt;uuid&gt;&quot;
}</code></pre><p>Note: we can monitor the progress by running:</p><pre><code>aws ec2 describe-import-snapshot-tasks --import-task-ids import-snap-&lt;uuid_from_above&gt; --region us-west-2
{
    &quot;ImportSnapshotTasks&quot;: [
        {
            &quot;SnapshotTaskDetail&quot;: {
                &quot;Status&quot;: &quot;active&quot;,
                &quot;Description&quot;: &quot;Linode Image&quot;,
                &quot;Format&quot;: &quot;RAW&quot;,
                &quot;DiskImageSize&quot;: 268435456000.0,
                &quot;UserBucket&quot;: {
                    &quot;S3Bucket&quot;: &quot;&lt;s3_bucket_name&gt;&quot;,
                    &quot;S3Key&quot;: &quot;linode.img&quot;
                },
                &quot;Progress&quot;: &quot;35&quot;,
                &quot;StatusMessage&quot;: &quot;downloading/converting&quot;
            },
            &quot;Description&quot;: &quot;Linode Image&quot;,
            &quot;ImportTaskId&quot;: &quot;import-snap-&lt;uuid&gt;&quot;
        }
    ]
}</code></pre><p>Once that import has completed, create a new EC2 image and <strong>let it boot</strong> - we need to grab a few files off of it!</p><p>While its booting, create a new volume of the desired size from the snapshot we just imported. Attach it to the instance and log into the instance once it has booted.</p><p>Mount the new volume to <code>/mnt</code> as shown here:</p><pre><code># NOTE: your exact path might differ, use lsblk command to see what the 
# correct path is

mount /dev/nvme2n1p1 /mnt 
</code></pre><p>We&apos;ll need to re-install grub on the new disk image, specify root-directory as <code>/mnt</code> since thats where we mounted the image:</p><pre><code>grub-install --recheck --debug --root-directory=/mnt /dev/nvme2n1
</code></pre><p>Next prepare to <code>chroot</code> into the image by mounting the required filesystems:</p><pre><code>for i in /dev /dev/pts /proc /sys /run; do sudo mount -B $i /mnt$i; done</code></pre><p>We&#x2019;ll also grab the netplan config from the &#xA0;ec2 instance to apply to the new image:</p><pre><code>cp /etc/network/interfaces.d/50-cloud-init.cfg /mnt/etc/network/interfaces.d/50-cloud-init.cfg</code></pre><h4 id="important-run-a-blkid-to-get-the-uuid-of-your-new-image-and-save-that-uuid-for-below">IMPORTANT: run a <code>blkid</code> to get the UUID of your new image and save that UUID for below</h4><p>Since we imported the image and created a new volume, the UUID will have changed, we need to update <code>/etc/fstab</code> or else we won&apos;t be able to boot!</p><p>Then we&apos;ll finally <code>chroot</code> in for the last few changes</p><pre><code>chroot /mnt

# change the hostname to your desired hostname

echo &apos;yourhostname&apos; &gt; /etc/hostname

# don&apos;t forget to update /etc/hosts with your desired hostname

vi /etc/hosts

# edit /etc/fstab with the new UUID for your image you got from 
# the blkid command above

vi /etc/fstab

# then update grub

update-grub

# thats it! If you have anything else you want to update, do that now.

exit</code></pre><p>Unmount the filesystems:</p><pre><code>for i in /dev /dev/pts /proc /sys /run; do umount /mnt$i ; done

unmount /mnt</code></pre><p>Finally shut down the EC2 instance, and disconnect the volumes from it, then remount the new volume you just created from the snapshot as <code>/dev/sda1</code> and reboot the EC2. You should now be able to log in to your clone of your Linode!</p><p>This process was long and painful to figure out, but I wanted to capture this process to demonstrate that you can still do it if you don&apos;t have the ability to create a &apos;local to Linode&apos; disk image. For the easier path, follow along with the next section.</p><h3 id="create-disk-image-to-linode-second-disk-simple-fast-method-">Create disk image to Linode second disk (simple/fast method)</h3><h6 id="note-if-you-use-this-method-you-must-have-aws-cli-access-as-this-method-must-use-the-aws-cli-tools-to-import-the-disk-image-">NOTE: if you use this method, you MUST have AWS CLI access as this method must use the AWS CLI tools to import the disk image.</h6><p>This is a much simpler/faster method, which is the recommended path if you are able to create a local image in your Linode instance, based on your disk space available. Its adapted from Devon Kurland&apos;s post <a href="https://devonkurland.com/importing-a-linode-vps-into-aws-ec2/">here</a>.</p><p>If you&apos;ve chosen to create your disk image on a second disk in your Linode, you&apos;ll want to do the following steps:</p><ul><li>Shut down your Linode</li><li>Add a second disk that is larger than your primary disk (so you&apos;ll have enough room for the disk image to be created)</li><li>Set the new disk to be <code>/dev/sdb</code> in the Linode console</li><li>Boot into Finnix recovery mode</li></ul><p>Now, connect via <a href="https://www.linode.com/docs/platform/manager/remote-access/#console-access">Lish</a> for the rest of the commands.</p><pre><code># First, install required tools to the Finnix recovery environment

apt-get update
apt-get install python-pip python-setuptools ca-certificates grub2
# When prompted where to install GRUB2, just press Enter, and then select Yes to continue without installing.

# Install the AWS CLI which we&apos;ll use to import the image
pip install awscli

# Next, mount the new disk that we&apos;ll be creating the backup on and create the server.raw file

mount /dev/sdb /mnt ; cd /mnt
dd if=/dev/zero of=server.raw count=1 bs=1MiB

# Next, copy your Linode primary disk to the raw file
dd bs=1MiB seek=1 if=/dev/sda of=server.raw

# Next, some quick fdisk prep, create a partition and write it
fdisk server.raw
# Press n, accept all of the defaults, then a and w.

# Next, use https://linux.die.net/man/8/kpartx to create a device map from the image so we can then mount it

kpartx -a -v server.raw

mount /dev/mapper/loop1p1 /mnt
# If you receive a &quot;does not exist&quot; error, you may need to run the last two commands again.

# next we&apos;ll (re) install grub to the image we just mounted as a loop device. This ensures the MBR has grub installed

grub-install --recheck --debug --boot-directory=/mnt/boot/ /dev/loop1

# Prepare to chroot into the new image
mount -t proc proc /mnt/proc/ ; mount -t sysfs sys /mnt/sys/
mount -o bind /dev /mnt/dev/
chroot /mnt

# Configure the new image, verify grub is installed and re-update it
apt-get install grub2-common linux-image-amd64
cp /usr/share/grub/default/grub /etc/default/grub
update-grub2

# Update our fstab with the new disk UUID
sed -i &quot;s/insmod ext2/insmod ext2\nset root=&apos;hd0,msdos1&apos;/g&quot;
echo &quot;UUID=`blkid -s UUID -o value /dev/sda` / ext4 defaults 1 1&quot; &gt; /etc/fstab
printf &quot;nameserver 8.8.8.8\nnameserver 8.8.4.4&quot; &gt; /etc/resolv.conf
printf &quot;auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp&quot; &gt; /etc/network/interfaces

# Do any other steps you might want to do, then exit
exit

# Unmount all the filesystems
for i in proc sys dev ; do umount /mnt/$i ; done
umount /mnt

# Remove the device maps

kpartx -d -v server.raw</code></pre><p>Once you&apos;re done here, the next 2 commands will import your image to S3, then to EC2 as an image.</p><pre><code>aws s3 cp server.raw s3://&lt;your_s3_bucket_name&gt;/&lt;image_path&gt;</code></pre><pre><code>aws ec2 import-image --cli-input-json &quot;{\&quot;Description\&quot;:\&quot;server\&quot;,\&quot;DiskContainers\&quot;:[{\&quot;Description\&quot;:\&quot;Imported from Linode\&quot;,\&quot;UserBucket\&quot;:{\&quot;S3Bucket\&quot;:\&quot;bucketname\&quot;,\&quot;S3Key\&quot;:\&quot;server.raw\&quot;}}]}&quot;</code></pre><p>To monitor the import task you can run the following command:</p><pre><code>aws ec2 describe-import-image-tasks</code></pre><p>Once your import is complete you can navigate to &quot;My AMI&apos;s&quot; and create an EC2 instance from there. </p>]]></content:encoded></item><item><title><![CDATA[Debugging a PHP app in Kubernetes using Telepresence.io]]></title><description><![CDATA[<!--kg-card-begin: markdown--><p>Hello folks,</p>
<p>Today we&apos;re going to talk about using <a href="https://www.telepresence.io">telepresence.io</a> to debug PHP code running in Kubernetes using Telepresence. I&apos;ll refer you to the <a href="https://www.telepresence.io/discussion/overview">Telepresence Introduction</a> page for an overview of what Telepresence can do for you, but if you&apos;re reading this you</p>]]></description><link>https://blog.badgerops.net/debugging-a-php-app-in-kubernetes-using-telepresence-io/</link><guid isPermaLink="false">653505aeed6b250001793aa5</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Thu, 03 Oct 2019 19:01:21 GMT</pubDate><content:encoded><![CDATA[<!--kg-card-begin: markdown--><p>Hello folks,</p>
<p>Today we&apos;re going to talk about using <a href="https://www.telepresence.io">telepresence.io</a> to debug PHP code running in Kubernetes using Telepresence. I&apos;ll refer you to the <a href="https://www.telepresence.io/discussion/overview">Telepresence Introduction</a> page for an overview of what Telepresence can do for you, but if you&apos;re reading this you probably are already aware and just want the example code. So, let&apos;s get to it!</p>
<p><mark>if you want to skip all the discussion and get right to hacking, just scroll past the break for &apos;gimme the code, man&apos; section</mark></p>
<p>Once you&apos;ve read the Introduction, then the <a href="https://www.telepresence.io/reference/install">installation guide</a> is your next stop. The specific version of Telepresence that introduced the ability to use xdebug for remote debugging is <em>0.102 (October 2, 2019)</em>  <a href="https://www.telepresence.io/reference/changelog">you can read the changelog here</a> if you&apos;re interested in the details.</p>
<p>Alright, so lets set some assumptions here:</p>
<p>1: you are comfortable with PHP and using xdebug</p>
<ul>
<li>
<p>There are plenty of guides to getting xdebug working on the internet, I used the <a href="https://www.jetbrains.com/help/phpstorm/configuring-xdebug.html">phpstorm guide</a></p>
</li>
<li>
<p>Note: I am also using the <a href="jetbrains.com/help/phpstorm/browser-debugging-extensions.html">browser debugger extension</a> for chrome</p>
</li>
</ul>
<p>2: I am using phpstorm for this example, feel free to follow along with your favorite editor</p>
<p>3: I am doing this from a Mac, but you could just as easily use Linux, all of the tools I use also work there. Windows, I honestly have no idea as I don&apos;t use Windows for any development work.</p>
<p>4: I am using both Kubernetes on my Docker for Desktop on Mac and EKS in AWS.</p>
<h4 id="setupyourenvironment">Setup your environment</h4>
<p>For this example, I&apos;m just going to do a very simple &apos;hello world&apos; PHP page that will also expose <code>phpinfo()</code> so you can see the environment variables.</p>
<h6 id="step1">Step 1:</h6>
<p>Create yourself a new project in your <code>$editor_of_choice</code> and create your index.php with &apos;hello world&apos; and/or <code>phpinfo()</code> in it.</p>
<p><mark>Note, if you&apos;re not using a Unix compliant shell, then don&apos;t use the cat &gt; <code>file</code> &lt;&lt; EOF line, more info on heredoc <a href="https://en.wikipedia.org/wiki/Here_document#Unix_shells">here</a></mark></p>
<pre><code>mkdir -p ~/code/telepresenceDemo &amp;&amp; cd ~/code/telepresenceDemo

cat &gt; index.php &lt;&lt; EOF
&lt;html&gt;
 &lt;head&gt;
  &lt;title&gt;PHP Test&lt;/title&gt;
 &lt;/head&gt;
 &lt;body&gt;
 &lt;?php echo &apos;&lt;p&gt;Hello World!&lt;/p&gt;&apos;; ?&gt;
 &lt;?php phpinfo(); ?&gt;
 &lt;/body&gt;
&lt;/html&gt;

EOF
</code></pre>
<h6 id="step2">Step 2:</h6>
<p>Create a Dockerfile to test with. I&apos;m using the <a href="https://hub.docker.com/_/php">official PHP</a>  apache docker build (apache for single container goodness. PHP-FPM + Nginx are usable as well and I may cover them in a future post)</p>
<p>This installs and enables xdebug and sets some custom xdebug options in the php.ini file. We also copy the index.php we created to <code>/var/www/html/index.php</code></p>
<pre><code>cat &gt; Dockerfile &lt;&lt; EOF
FROM php:7.2-apache
RUN pecl install xdebug-2.6.0
RUN docker-php-ext-enable xdebug
RUN echo &quot;xdebug.remote_enable=1&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \
    echo &quot;xdebug.remote_host=localhost&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \
    echo &quot;xdebug.remote_port=9000&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \ 
    echo &quot;xdebug.remote_log=/var/log/xdebug.log&quot; &gt;&gt; /usr/local/etc/php/php.ini 
    

COPY ./index.php /var/www/html
WORKDIR /var/www/html

EOF
</code></pre>
<h6 id="step3">Step 3:</h6>
<p>build our example container</p>
<pre><code>docker build -t mytelepresencetest:01 .
</code></pre>
<p>This container pulls from <a href="https://hub.docker.com/_/php">upstream php</a> and we add our xdebug specific settings to it in the above Dockerfile.</p>
<h6 id="step4">Step 4:</h6>
<p>Finally, assuming you already have <code>KUBECONFIG</code> set we can fire up Telepresence. Telepresence will use whatever Kubernetes config file you have in your env, or in <code>~/.kube/config</code>.</p>
<ul>
<li><a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/">more info on Kubernetes config file management here</a></li>
</ul>
<pre><code>telepresence --container-to-host 9000 --verbose --new-deployment tele-test --docker-run -p 8080:80 -v $(pwd):/var/www/html mytelepresencetest:01
</code></pre>
<p>In this example, I&apos;m mounting the local (pwd) directory to <code>/var/www/html</code> which allows me to edit the index.php in my editor and have it automatically reflect inside the container we&apos;re running. You could also specify the explicit path to your code folder if you don&apos;t launch Telepresence from the code folder.</p>
<p>If you created the folder as noted above, this would look like:</p>
<pre><code>telepresence --container-to-host 9000 --verbose --new-deployment tele-test --docker-run -p 8080:80 -v ~/code/telepresenceDemo:/var/www/html mytelepresencetest:01
</code></pre>
<p>Here is how this command looks as it executes on my machine (on October 3rd 2019)</p>
<pre><code>telepresence --container-to-host 9000 --verbose --new-deployment tele-test --docker-run -p 8080:80 -v $(pwd):/var/www/html mytelepresencetest:01
T: How Telepresence uses sudo: https://www.telepresence.io/reference/install#dependencies
T: Invoking sudo. Please enter your sudo password.
Password:
T: Volumes are rooted at $TELEPRESENCE_ROOT. See https://telepresence.io/howto/volumes.html for details.
T: Starting network proxy to cluster using new Deployment tele-test

T: No traffic is being forwarded from the remote Deployment to your local machine. You can use the --expose option to specify which ports you want to forward.

T: Forwarding container port 9000 to host port 9000.
T: Setup complete. Launching your container.
AH00558: apache2: Could not reliably determine the server&apos;s fully qualified domain name, using 172.17.0.2. Set the &apos;ServerName&apos; directive globally to suppress this message
AH00558: apache2: Could not reliably determine the server&apos;s fully qualified domain name, using 172.17.0.2. Set the &apos;ServerName&apos; directive globally to suppress this message
[Thu Oct 03 17:04:35.421678 2019] [mpm_prefork:notice] [pid 7] AH00163: Apache/2.4.38 (Debian) PHP/7.2.23 configured -- resuming normal operations
[Thu Oct 03 17:04:35.422032 2019] [core:notice] [pid 7] AH00094: Command line: &apos;apache2 -D FOREGROUND&apos;
</code></pre>
<p>Awesome, we see that apache has launched, and we can see the apache logs in our terminal window.</p>
<h6 id="step5">Step 5:</h6>
<p>Open a browser to <a href="http://localhost:8080">http://localhost:8080</a> we should see our &quot;Hello, World&quot; statement followed by <code>phpinfo()</code> output. Here is what you should see in your Telepresence output:</p>
<pre><code>(this line included for continuity from above example block) 

[Thu Oct 03 17:04:35.422032 2019] [core:notice] [pid 7] AH00094: Command line: &apos;apache2 -D FOREGROUND&apos;

172.17.0.1 - - [03/Oct/2019:17:11:08 +0000] &quot;GET / HTTP/1.1&quot; 200 25347 &quot;-&quot; &quot;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36&quot;
172.17.0.1 - - [03/Oct/2019:17:11:13 +0000] &quot;GET /favicon.ico HTTP/1.1&quot; 404 502 &quot;http://localhost:8080/&quot; &quot;Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36&quot;
</code></pre>
<p>The key line here is <code>--container-to-host 9000</code> - this creates a connection from the container back to your computer at <code>localhost:9000</code> so your xdebug listener can receive data from the code executing in that container.</p>
<p>Now that you have your Telepresence process running swap over to your Editor - again, I&apos;m using PHPStorm + Chrome + the PHPStorm xdebug exension - and turn on your xdebug listener. Also turn on your xdebug extension in your browser. (Configuration instruction links are included for both of these in the top of this blog post under assumption #1)</p>
<p>Once those are running you should be able to refresh your page and see a breakpoint hit in PHPStorm! (The first time you should get a pop up asking for you to map the code to match what you have in your local path vs remote path)</p>
<p>In your editor, go modify your <code>index.php</code> to be &quot;Hello, Telepresence&quot; instead of &quot;Hello, World&quot; and refresh your browser to see the changes reflected in the container.</p>
<p>Now if this were an app that needed access to resources hosted in your Kubernetes cluster, you&apos;d be able to hit those resources from your code that is technically running &apos;locally&apos; on your box, giving you the ability to hit breakpoints and step through code without having to host all of those resources locally. Nice. Huge shoutout to awesome folks at Datawire who wrote this tool!</p>
<p>The way this all works is Telepresence spins up 2 proxy containers - one in Docker locally on your box, the other in your Kubernetes cluster. Then it routes all traffic from your local Docker container you built and ran through the &apos;local&apos; Docker proxy container, to the remote Kubernetes proxy container with this chunk of the command:<br>
<code>--docker-run -p 8080:80 -v $(pwd):/var/www/html mytelepresencetest:01</code><br>
through the Kubernetes proxy side, giving you access to any resources your Kubernetes cluster has available.</p>
<p>If this doesn&apos;t work for you - make sure you&apos;ve correctly configured your editor as noted in Assumption #1 at the top of this post. If you&apos;re still having issues, swing by twitter and ask <a href="https://twitter.com/badgerops">@badgerops</a> or check the <a href="https://github.com/telepresenceio/telepresence">github issues</a> to see if someone else is having a similar issue.</p>
<p>If you think Telepresence is awesome and want to contribute, <a href="https://github.com/telepresenceio/telepresence">head over to their github</a> and get your sweet <a href="https://hacktoberfest.digitalocean.com/">Hacktoberfest</a> PR&apos;s in. (Assuming you&apos;re reading this in October!)</p>
<hr>
<h2 id="justthestepsakagimmethecodeman">Just the steps aka, &apos;gimme the code, man&apos;</h2>
<h6 id="step1">Step 1:</h6>
<p>Create yourself a new project in your <code>$editor_of_choice</code> and create your index.php with &apos;hello world&apos; and/or <code>phpinfo()</code> in it.</p>
<h6 id="orifyouwantmanualsteps">Or, if you want manual steps:</h6>
<p><mark>Note, if you&apos;re not using a Unix compliant shell, then don&apos;t use the cat &gt; <code>file</code> &lt;&lt; EOF line, more info on heredoc <a href="https://en.wikipedia.org/wiki/Here_document#Unix_shells">here</a></mark></p>
<p>Create your code folder</p>
<pre><code>mkdir -p ~/code/telepresenceDemo &amp;&amp; cd ~/code/telepresenceDemo
</code></pre>
<p>Create your index.php file</p>
<pre><code>cat &gt; index.php &lt;&lt; EOF
&lt;html&gt;
 &lt;head&gt;
  &lt;title&gt;PHP Test&lt;/title&gt;
 &lt;/head&gt;
 &lt;body&gt;
 &lt;?php echo &apos;&lt;p&gt;Hello World!&lt;/p&gt;&apos;; ?&gt;
 &lt;?php phpinfo(); ?&gt;
 &lt;/body&gt;
&lt;/html&gt;

EOF
</code></pre>
<h6 id="step2">Step 2:</h6>
<p>Create a Dockerfile to test with. I&apos;m using the <a href="https://hub.docker.com/_/php">official PHP</a>  apache docker build (apache for single container goodness. PHP-FPM + Nginx are usable as well)</p>
<pre><code>cat &gt; Dockerfile &lt;&lt; EOF
FROM php:7.2-apache
RUN pecl install xdebug-2.6.0
RUN docker-php-ext-enable xdebug
RUN echo &quot;xdebug.remote_enable=1&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \
    echo &quot;xdebug.remote_host=localhost&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \
    echo &quot;xdebug.remote_port=9000&quot; &gt;&gt; /usr/local/etc/php/php.ini &amp;&amp; \ 
    echo &quot;xdebug.remote_log=/var/log/xdebug.log&quot; &gt;&gt; /usr/local/etc/php/php.ini 
    

COPY ./index.php /var/www/html
WORKDIR /var/www/html

EOF
</code></pre>
<h6 id="step3">Step 3:</h6>
<p>Build our example container</p>
<pre><code>docker build -t mytelepresencetest:01 .
</code></pre>
<h6 id="step4">Step 4:</h6>
<p><s>Profit</s> Run the thing (Assuming pwd is your code repo):</p>
<pre><code>telepresence --container-to-host 9000 --verbose --new-deployment tele-test --docker-run -p 8080:80 -v $(pwd):/var/www/html mytelepresencetest:01
</code></pre>
<h6 id="step5">Step 5:</h6>
<p>Start up xdebug listener in your editor, open your browser to <a href="http://localhost:8080">http://localhost:8080</a> and start stepping through code! The key line here is <code>--container-to-host 9000</code> - this creates a connection from the container back to your computer at <code>localhost:9000</code> so your xdebug listener can receive data from the code executing in that container.</p>
<p>If this doesn&apos;t work for you - make sure you&apos;ve correctly configured your editor as noted in Assumption #1 at the top of this post. If you&apos;re still having issues, swing by twitter and ask <a href="https://twitter.com/badgerops">@badgerops</a> or check the <a href="https://github.com/telepresenceio/telepresence">github issues</a> to see if someone else is having a similar issue.</p>
<p>If you think Telepresence is awesome and want to contribute, <a href="https://github.com/telepresenceio/telepresence">head over to their github</a> and get your sweet <a href="https://hacktoberfest.digitalocean.com/">Hacktoberfest</a> PR&apos;s in. (Assuming you&apos;re reading this in October!)</p>
<p>Hope this helps you out, Cheers!</p>
<p>-BadgerOps</p>
<p>twitter: <a href="https://twitter.com/badgerops">@badgerops</a></p>
<!--kg-card-end: markdown-->]]></content:encoded></item><item><title><![CDATA[Forcing initramfs to load udev 70-persistent-net.rules]]></title><description><![CDATA[<!--kg-card-begin: markdown--><p>Hello fine readers,</p>
<p>Chances are you&apos;re scouring the googles much like I was all morning trying to figure out how to get <code>update-initramfs</code> to pull in the <code>70-persistent-net.rules</code> udev rule.</p>
<p>You may have stumbled on <a href="https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=618420">this debian bug</a> or <a href="https://bugs.launchpad.net/ubuntu/+source/initramfs-tools/+bug/1471391">this ubuntu bug</a> which coincidently ties in with</p>]]></description><link>https://blog.badgerops.net/forcing-initramfs-to-load-udev-70-persistent-net-rules/</link><guid isPermaLink="false">653505aeed6b250001793aa3</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Wed, 14 Feb 2018 23:09:10 GMT</pubDate><content:encoded><![CDATA[<!--kg-card-begin: markdown--><p>Hello fine readers,</p>
<p>Chances are you&apos;re scouring the googles much like I was all morning trying to figure out how to get <code>update-initramfs</code> to pull in the <code>70-persistent-net.rules</code> udev rule.</p>
<p>You may have stumbled on <a href="https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=618420">this debian bug</a> or <a href="https://bugs.launchpad.net/ubuntu/+source/initramfs-tools/+bug/1471391">this ubuntu bug</a> which coincidently ties in with my <a href="https://blog.badgerops.net/2018/01/16/using-dropbear-ssh-daemon-to-enable-remote-luks-unlocking/">remote LUKS unlocking</a> post.</p>
<p>I quickly found that <code>NEED_PERSISTENT_NET=yes</code> needed to be set, but it wasn&apos;t immediately obvious <em>where</em> that needed to be set as <code>update-initramfs</code> ignores environment variables. Well, I finally <a href="https://git.devuan.org/jaretcantu/eudev/blob/9dc53b6a06d64e349ee1aa9a69b022586eea95ab/debian/udev.README.Debian#L146">tracked down</a>  a reference to where you can set that variable.</p>
<h6 id="note">NOTE:</h6>
<p>In the case that link dies, or that line is removed here is the necessary information:</p>
<blockquote></blockquote>
<p>Usually network interfaces are renamed after the root file system has been mounted, so if the root file system is mounted over the network then the 70-persistent-net.rules file must be copied to the initramfs. In most cases this is done automatically, but some setups may require explicitly setting <code>&quot;export NEED\_PERSISTENT_NET=yes&quot; in a file in /etc/initramfs-tools/conf.d/ </code>. If 70-persistent-net.rules is copied to the initramfs then it must be updated every time a new interface is added.</p>
<p>and added it to my deployment script:</p>
<pre><code>echo &quot;export NEED_PERSISTENT_NET=yes&quot; &gt; /mnt/etc/initramfs-tools/conf.d/persistent_net_setup
</code></pre>
<p>which triggered the copying of  <code>70-persistent-net.rules</code> the next time I ran <code>update-initramfs</code></p>
<p>Hopefully this helps you out!</p>
<p>-BadgerOps</p>
<!--kg-card-end: markdown-->]]></content:encoded></item><item><title><![CDATA[Using Saltstack salt-mine]]></title><description><![CDATA[<!--kg-card-begin: markdown--><h5 id="editinmarchof2020hellothisisoneofmymorepopularpostsevenin2020butimcuriousifyoucameacrossthispostlookingforslightlydifferentinformationthanispresentedifsoshootmeamessageontwitterbadgeropsorsendanemailtoblogbadgeropsnetwithwhatyourelookingforsoicanupdatethispostthankyou">Edit in March of 2020: Hello! This is one of my more popular posts even in 2020, but I&apos;m curious if you came across this post looking for slightly different information than is presented. If so, shoot me a message on twitter: <code>@badgerops</code> or send an email to</h5>]]></description><link>https://blog.badgerops.net/using-saltstack-salt-mine/</link><guid isPermaLink="false">653505aeed6b250001793aa1</guid><dc:creator><![CDATA[BadgerOps]]></dc:creator><pubDate>Mon, 12 Feb 2018 16:10:54 GMT</pubDate><content:encoded><![CDATA[<!--kg-card-begin: markdown--><h5 id="editinmarchof2020hellothisisoneofmymorepopularpostsevenin2020butimcuriousifyoucameacrossthispostlookingforslightlydifferentinformationthanispresentedifsoshootmeamessageontwitterbadgeropsorsendanemailtoblogbadgeropsnetwithwhatyourelookingforsoicanupdatethispostthankyou">Edit in March of 2020: Hello! This is one of my more popular posts even in 2020, but I&apos;m curious if you came across this post looking for slightly different information than is presented. If so, shoot me a message on twitter: <code>@badgerops</code> or send an email to <code>blog@badgerops.net</code> with what you&apos;re looking for so I can update this post! Thank you.</h5>
<hr>
<p>Today we&apos;re going to talk about using salt-mine to help gather information from salt minions. This is a sister post to <a href="https://blog.badgerops.net/2018/02/09/using-the-saltstack-pyobjects-renderer/">Using the #!pyobjects renderer</a> as we&apos;re consuming mine data to create a custom hosts file.</p>
<p>In this example, we&apos;re going to register our IP addresses that match a specific IP address pattern, or <a href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing">CIDR</a> using <a href="https://docs.saltstack.com/en/latest/topics/mine/">salt-mine</a></p>
<p>using a pillar declaration as seen here: (NOTE: this is explained in great detail <a href="https://docs.saltstack.com/en/latest/topics/mine/">in the documentation</a></p>
<pre><code>mine_functions:
  # we build our /etc/hosts file off the &apos;private/non routable&apos; IP&apos;s
  network.ip_addrs:
    cidr: 192.168.0.0/16
</code></pre>
<p>This allows us to do a mine lookup <code>salt[&apos;mine.get&apos;](&apos;*&apos;, &apos;network.ip_addrs&apos;)</code> which would return a dictionary that looks something like this:</p>
<pre><code>&gt;&gt;&gt; salt(&apos;*&apos;, &apos;mine.get&apos;, (&apos;*&apos;, &apos;network.ip_addrs&apos;))
{&apos;saltmaster&apos;: {&apos;saltmaster&apos;: [&apos;192.168.50.4&apos;], &apos;linux-1&apos;: [&apos;192.168.50.5&apos;]}, &apos;linux-1&apos;: {&apos;saltmaster&apos;: [&apos;192.168.50.4&apos;], &apos;linux-1&apos;: [&apos;192.168.50.5&apos;]}}
</code></pre>
<p>breaking this down: <code>salt(&apos;*&apos;</code> is functionally the same as <code>salt &apos;*&apos;</code> meaning we run the command on all minions. Then we have the <code>mine.get</code> function, where we pass in <code>(&apos;*&apos;, &apos;network.ip_addrs&apos;)</code> as arguments. This mean&apos;s we&apos;re requesting network.ip_addrs from all the minions. As usual, if you <a href="https://docs.saltstack.com/en/latest/topics/mine/#example">read the documentation</a> you should have a better understanding of how to get information back out of salt-mine.</p>
<p>We could omit the cidr to register all IP&apos;s <a href="https://docs.saltstack.com/en/latest/ref/modules/all/salt.modules.network.html#salt.modules.network.ipaddrs">except loopback</a> but that would be functionally equivalent to <code>salt-call grains.get network.ipaddrs</code> (to be clear, that is exactly what is happening under the hood, we&apos;re just storing that return in the salt-mine)</p>
<p>There are many other things we could store in the salt-mine, essentially any grain you can look up, or set, can be stored in the salt-mine, one huge reason for this is you can look up data about one minion from another minion, which is something that you cannot do with pillar or grains (they&apos;re minion specific), this is why I chose to use salt-mine to create my custom /etc/hosts file.</p>
<p>Hope this brief post was helpful, feel free to comment or hit up <code>@badgerops</code> on twitter!</p>
<p>-BadgerOps</p>
<!--kg-card-end: markdown-->]]></content:encoded></item></channel></rss>