[{"content":"Every network team has the same story: a switch dies, the replacement arrives, and then someone asks \u0026ldquo;where\u0026rsquo;s the latest config?\u0026rdquo; Usually the answer is a .txt on somebody\u0026rsquo;s laptop from eight months ago.\nI wanted backups that happen without anyone thinking about them, for a network of office switches spread over a lot of sites. No extra VM to babysit, no cron box under a desk. This is what I ended up with.\nThe big picture EventBridge (cron) ──► Lambda (VPC) ──SSH──► switches (on-prem) │ ├──► S3: configs by date / site └──► SNS: email summary EventBridge fires on a schedule. A Lambda function (Python + Paramiko) reads the list of switches, logs in over SSH and grabs show running-config from each one, in parallel. Configs land in S3, sorted by date and site. SNS emails a summary: which devices succeeded, which failed and why. Everything is Terraform, deployed from a GitLab pipeline. The inventory is just CSV files in git Each site has a small semicolon-separated file: hostname and management IP.\nSITE1-SW01;192.0.2.11 SITE1-SW02;192.0.2.12 Terraform uploads those files to an S3 bucket with aws_s3_object resources. That sounds like a detail, but it\u0026rsquo;s my favourite part: adding a switch is a merge request, there\u0026rsquo;s history for free, and the bucket never drifts from what\u0026rsquo;s in git.\nThe site name is parsed from the hostname, so backups end up neatly organised:\ns3://\u0026lt;backup-bucket\u0026gt;/cisco-backups/2026-10-13/SITE1/SITE1-SW01_running_config.txt Getting a Lambda onto the management network This is where most of the actual work went. The switches live on-prem, so the Lambda runs inside a VPC that has a route back to the on-prem network (in my case through a Transit Gateway).\nA Lambda in a VPC has no internet access by default, but it still needs to talk to S3, Secrets Manager and SNS. Instead of a NAT gateway, I added VPC endpoints:\nS3: a gateway endpoint (free, just a route table entry) Secrets Manager and SNS: interface endpoints, each with its own security group That keeps AWS API traffic inside AWS instead of hairpinning over the on-prem link.\nTalking to the switches The Lambda uses Paramiko to open an interactive shell, because it needs to handle enable mode:\nconnect, wait for the login banner to finish, and read the prompt (SITE1-SW01\u0026gt; or SITE1-SW01#) if it ends in \u0026gt;, send enable and the enable password terminal length 0 so the output isn\u0026rsquo;t paginated show running-config, and read until that exact prompt comes back Reading until the device\u0026rsquo;s own prompt matters: a # can show up anywhere in a config (banners, description Uplink #2), so \u0026ldquo;stop at the first #\u0026rdquo; isn\u0026rsquo;t good enough. And a config is only saved if it ends with the end line; anything else counts as a failure, never as a half backup.\nSwitches are processed with a ThreadPoolExecutor (up to 50 at a time), so a full run finishes well within Lambda\u0026rsquo;s 15-minute limit. 512 MB of memory is plenty: a run over the lab switches takes about 3 seconds and uses ~110 MB.\nCredentials live in Secrets Manager, not in the code or the Terraform: a primary account (the normal AAA login), plus a fallback with local credentials for when AAA is unreachable. If the primary login fails, the fallback is tried. If the switch can\u0026rsquo;t be reached at all, it isn\u0026rsquo;t: other credentials won\u0026rsquo;t make a dead switch answer.\nOne function, several jobs The Lambda takes its parameters from the event, so the same code covers several schedules with a different input each time:\nresource \u0026#34;aws_cloudwatch_event_target\u0026#34; \u0026#34;switch_backup\u0026#34; { rule = aws_cloudwatch_event_rule.daily_2am.name arn = aws_lambda_function.device_backup.arn input = jsonencode({ csv_bucket = var.csv_bucket_name csv_prefix = \u0026#34;switches/\u0026#34; output_prefix = \u0026#34;switch-backup\u0026#34; }) } Switches: every night at 02:00 UTC Wireless LAN controllers: weekly, same function, csv_prefix = \u0026quot;WLC/\u0026quot; \u0026ldquo;Tech dump\u0026rdquo;: a second Lambda that runs a list of show commands (show version, show interface status, show cdp neighbors, \u0026hellip;) every Sunday. Handy as a weekly snapshot of what the network looks like, not just how it\u0026rsquo;s configured. Storage that costs next to nothing Config files are tiny, but they pile up. The backup bucket uses:\nIntelligent-Tiering archive: Archive Access after 90 days, Deep Archive after 180 a lifecycle rule that deletes objects after 365 days Knowing it actually ran A backup job that fails silently is worse than none, because everyone thinks there are backups. So:\nevery run emails a per-device summary through SNS, and the subject says how many devices failed (Device Backup Summary: 2 failed) CloudWatch alarms fire on Lambda errors, throttles and runs that get close to the timeout an alarm fires when any device fails That last one needed a trick. A run where three switches fail is still a successful Lambda invocation, so the built-in Errors metric never sees it. The Lambda logs its counts in Embedded Metric Format, a JSON log line that CloudWatch turns into a metric by itself:\n{\u0026#34;_aws\u0026#34;: {\u0026#34;CloudWatchMetrics\u0026#34;: [{\u0026#34;Namespace\u0026#34;: \u0026#34;NetworkBackup\u0026#34;, \u0026#34;Dimensions\u0026#34;: [[\u0026#34;FunctionName\u0026#34;]], \u0026#34;Metrics\u0026#34;: [{\u0026#34;Name\u0026#34;: \u0026#34;FailedDevices\u0026#34;, \u0026#34;Unit\u0026#34;: \u0026#34;Count\u0026#34;}]}]}, \u0026#34;FunctionName\u0026#34;: \u0026#34;device-backup\u0026#34;, \u0026#34;FailedDevices\u0026#34;: 0} No PutMetricData call, no extra IAM permission, and no extra VPC endpoint for a Lambda that has no internet access.\nDeploying it: GitLab CI with OIDC Stage What happens test unit tests against a fake Cisco switch validate terraform validate plan terraform plan, which also builds the Lambda ZIPs deploy terraform apply, only on main GitLab authenticates to AWS with OIDC: the pipeline assumes an IAM role through a short-lived token. No long-lived AWS access keys stored anywhere. State lives in GitLab\u0026rsquo;s managed Terraform state, with locking.\nThe tests run against a tiny fake switch: an SSH server written with Paramiko that mimics IOS (\u0026gt; and # prompts, enable, a banner full of # characters, and output that trickles in slowly like over a WAN link). It\u0026rsquo;s about 180 lines and makes it easy to test the SSH logic without touching a real network.\nA few CI/CD details worth copying Build the Lambda ZIPs from source in Terraform (archive_file) instead of committing them, so what\u0026rsquo;s deployed is always what\u0026rsquo;s in git. The ZIP is created during plan and the separate apply job needs that file, so pass it along as a CI artifact.\nOnly deploy from main. Merge requests get tests and a plan they can review, nothing more.\nPin the AWS account. One line in the provider makes Terraform refuse to run with credentials for any other account:\nprovider \u0026#34;aws\u0026#34; { region = var.aws_region allowed_account_ids = [var.aws_account_id] } Know where your CI variables come from. GitLab variables can be set on the project and inherited from parent groups, and protected variables only exist on protected branches. Worth checking which AWS_ROLE_ARN a branch pipeline actually gets.\nWhat\u0026rsquo;s next Netmiko instead of raw Paramiko. The prompt handling works well, but Netmiko already knows the quirks of every Cisco platform. Diff the configs. Every night is a full copy. Committing them to a git repo (the way Oxidized or RANCID do) would give you a \u0026ldquo;what changed last night?\u0026rdquo; email for free. All in all it\u0026rsquo;s a handful of Terraform files and two Python functions, it costs pennies a month, and nobody has had to think about switch backups since.\n","permalink":"https://blog.ntcu.be/posts/cisco-switch-backups-aws-lambda/","summary":"A serverless setup that SSHes into every office switch each night, stores the running config in S3 and emails a summary, all deployed from git.","title":"Nightly Cisco switch backups with AWS Lambda, S3 and Terraform"},{"content":"Sending a password over email or Teams means it sits in someone\u0026rsquo;s mailbox (and a backup, and a search index) forever. I wanted something simple to hand credentials to customers and colleagues: generate a strong password, get a link, and have that link stop working after it\u0026rsquo;s been read.\nSo I built one. You can try it at password.ntcu.be.\nWhat it does Generates passwords in the browser with crypto.getRandomValues(). You pick the length and character sets; the generated password never touches the server unless you choose to share it. Shares a secret as a link, with either: an expiry time, one-time access (deleted after the first read), or a view limit (1 to 50 views). Encrypts in the browser. The server only ever sees ciphertext; the key lives in the link itself. Custom branding through environment variables (logo, colours, title), so it can look like your company\u0026rsquo;s tool. Architecture Three containers in a Docker Compose stack:\ninternet ──► nginx ──► app (Node.js / Express) ──► Redis (public) (internal network only) (internal network only) The app and redis containers sit on a Docker network marked internal: true, so they have no route to the outside world at all. Only nginx is exposed.\nEncryption: the server can\u0026rsquo;t read your secrets The secret is encrypted in the browser, with the Web Crypto API, before anything is sent. Each secret gets its own random 256-bit AES-GCM key:\nasync function encryptForLink(plaintext) { const keyBytes = crypto.getRandomValues(new Uint8Array(32)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await crypto.subtle.importKey(\u0026#34;raw\u0026#34;, keyBytes, \u0026#34;AES-GCM\u0026#34;, false, [\u0026#34;encrypt\u0026#34;]); const data = await crypto.subtle.encrypt( { name: \u0026#34;AES-GCM\u0026#34;, iv, additionalData: AAD }, key, new TextEncoder().encode(plaintext) ); return { ciphertext: { v: 1, iv: toBase64Url(iv), data: toBase64Url(new Uint8Array(data)) }, linkKey: toBase64Url(keyBytes), }; } Only the ciphertext goes to the server. The key goes into the share link, after the #:\nhttps://password.ntcu.be/3f2a…c91#k3Y9xQ… └─ id ─┘ └─ key: never sent to any server Browsers never send the part after # (the fragment) in a request. Not to my server, not to Cloudflare in front of it, not to the reverse proxy, and it doesn\u0026rsquo;t end up in any access log. The recipient\u0026rsquo;s browser fetches the ciphertext by id, takes the key from its own address bar and decrypts locally. After a successful reveal, the key is removed from the address bar.\nThe server adds its own AES-256-GCM layer on top before storing it in Redis, so a stolen Redis dump is double-encrypted noise.\nThe one thing this can\u0026rsquo;t protect against is a compromised server serving different JavaScript. That\u0026rsquo;s true for every web-based tool like this (PrivateBin, Yopass, Bitwarden Send). A strict Content-Security-Policy and zero third-party scripts keep that surface small.\nClick to reveal Opening a link doesn\u0026rsquo;t fetch anything. The recipient sees \u0026ldquo;Someone shared a secret with you\u0026rdquo; and a Reveal secret button. That matters more than it sounds: Outlook Safe Links, Teams and Slack previews \u0026ldquo;visit\u0026rdquo; links before a human does. If opening the page fetched the secret, a one-time link would be burned by a bot before the real recipient ever saw it.\nLet Redis do the expiring Every secret is stored with Redis\u0026rsquo;s EX option, so expiry isn\u0026rsquo;t something my code has to remember to clean up: when the TTL runs out, the key is simply gone.\nOne-time secrets are deleted as part of the read itself. View-limited secrets carry a counter; each read decrements it (keeping the remaining TTL), and the last read deletes the key. Reading and consuming a view happen in one small Lua script, which Redis runs atomically. Two requests hitting a one-time link at the same moment can\u0026rsquo;t both get the secret: fire 10 in parallel and you get exactly one 200 and nine 404s.\nPersistence is optional. With AOF enabled, secrets survive a container restart. With it disabled, a restart wipes everything, which for some people is a feature.\nHardening Two layers of rate limiting per client IP: nginx (limit_req, 10 requests/s with a small burst, answering 429) in front of express-rate-limit in the app. The request path is Cloudflare → Nginx Proxy Manager → the app\u0026rsquo;s nginx, so nginx\u0026rsquo;s realip module recovers the visitor\u0026rsquo;s IP from X-Forwarded-For, trusting only private addresses and Cloudflare\u0026rsquo;s ranges. Otherwise every visitor would share one rate-limit bucket. Strict headers: Helmet plus nginx set a tight Content-Security-Policy (no inline scripts, no framing), nosniff, no-referrer. Cache-Control: no-store on secret responses, so they don\u0026rsquo;t end up in a browser or proxy cache. Boring 404s: secret IDs are UUIDv4 and validated; invalid, expired and already-read secrets all return the same 404 Not found. Input limits: 4096 characters per secret, 1 MB request bodies, expiry capped at 30 days. Deployment It runs on my homelab Docker host and deploys with the same push-to-deploy pipeline as everything else (see Self-hosting my code): a push to main builds the image on my runner, pushes it to my Forgejo registry and the Docker host pulls it.\nWhat\u0026rsquo;s next An optional passphrase, sent through a second channel (SMS, a call), so a leaked link alone isn\u0026rsquo;t enough. The ciphertext already carries a version number, so this can be added without breaking existing links. It\u0026rsquo;s a small project, but it replaced a lot of \u0026ldquo;I\u0026rsquo;ll send the password in a separate email\u0026rdquo; moments.\n","permalink":"https://blog.ntcu.be/posts/password-generator-secret-sharing/","summary":"A small Docker app that generates strong passwords and shares them through links that expire or self-destruct. Secrets are encrypted in the browser, so the server can\u0026rsquo;t read them.","title":"Building a self-hosted password generator with one-time secret links"},{"content":"For a long time my side projects were deployed the classic homelab way: SSH into the Docker host, git pull (if the project was even in git), docker compose up -d --build and hope for the best. One of my apps turned out to exist only on that Docker host. Time to fix that.\nThe goal Git hosting I control, on my own hardware CI runners I host myself git push to main = the change is live a minute later Easy rollback when something breaks The pieces Forgejo is the git server. It\u0026rsquo;s a lightweight, community-run fork of Gitea and it runs comfortably in a small Proxmox LXC container (2 cores, 2 GB RAM). It brings repositories, pull requests, a container registry and Forgejo Actions, which uses (almost) the same workflow syntax as GitHub Actions.\nA dedicated runner VM picks up the jobs. It has more CPU and memory than the Docker host, so all the image building happens there, with a warm build cache.\nThe Docker host no longer builds anything. It just pulls images and restarts containers.\nHow a deploy works git push ──► runner builds image ──► Forgejo registry ──► Docker host pulls + restarts (tagged with the commit) Every repo has a .forgejo/workflows/deploy.yml with two jobs: build and deploy. build builds the images and pushes them to the Forgejo registry, tagged with the full commit SHA. deploy SSHes into the Docker host with a key that can do exactly one thing: run a small deploy script. That script checks out that exact commit and runs the repo\u0026rsquo;s ci/deploy.sh, which pulls the images and restarts the containers. Because every image is tagged with its commit, rolling back is just re-running an older pipeline.\nLocking it down The deploy key is restricted with command= and from= in authorized_keys: it only works from the runner\u0026rsquo;s IP and can only run the deploy script. Secrets (.env files) never go into git; they only live on the Docker host. The registry push token can only write packages; the Docker host has a read-only token. Old images are cleaned up automatically by a registry cleanup rule. What\u0026rsquo;s next Tests that must pass before anything deploys A health check after deploy with automatic rollback Automatic dependency update PRs And this blog? It\u0026rsquo;s a Hugo site in the same Forgejo, deployed by the same pipeline. Writing this post was git commit \u0026amp;\u0026amp; git push.\n","permalink":"https://blog.ntcu.be/posts/self-hosted-git-and-ci/","summary":"How I moved my apps off GitHub to a self-hosted Forgejo with my own CI runner, a private container registry and push-to-deploy.","title":"Self-hosting my code: Forgejo, runners and push-to-deploy"},{"content":"Welcome to ntcu.be. This is where I\u0026rsquo;ll write about my homelab, networking and the things I self-host. More soon.\n","permalink":"https://blog.ntcu.be/posts/hello-world/","summary":"The obligatory first post.","title":"Hello, world"},{"content":"Hi, I\u0026rsquo;m Jens. I run a small homelab and write here about networking, self-hosting and whatever I\u0026rsquo;m currently tinkering with.\nThe name? ntcu.be is short for Netcue, my company: drop the E\u0026rsquo;s, add the Belgian .be.\nThis site is built with Hugo, stored in my own Forgejo instance and deployed automatically on every push.\n","permalink":"https://blog.ntcu.be/about/","summary":"\u003cp\u003eHi, I\u0026rsquo;m Jens. I run a small homelab and write here about networking,\nself-hosting and whatever I\u0026rsquo;m currently tinkering with.\u003c/p\u003e\n\u003cp\u003eThe name? \u003cem\u003entcu.be\u003c/em\u003e is short for \u003cstrong\u003eNetcue\u003c/strong\u003e, my company: drop the E\u0026rsquo;s, add\nthe Belgian \u003ccode\u003e.be\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThis site is built with \u003ca href=\"https://gohugo.io\"\u003eHugo\u003c/a\u003e, stored in my own\n\u003ca href=\"https://forgejo.org\"\u003eForgejo\u003c/a\u003e instance and deployed automatically on every push.\u003c/p\u003e","title":"About"}]