Validating Network Prerequisites for Veeam Kasten Integration: Building a Port Check Tool
If you've ever tried to stand up Veeam Kasten's integration with a VBR backup repository, you'll know the first thing that bites you usually isn't Kasten, and it isn't VBR — it's the network path in between. Worker nodes need to talk to the VBR server and repository on a specific set of ports, and when one of those is blocked, the failure mode is rarely a clean, obvious error. It's a stalled job, a vague timeout, or a Kopia repo that just won't initialize.
So instead of chasing that down manually on every engagement, I put together a small script to check it up front — and then took it a bit further than planned.
Starting point: what actually needs to talk to what
Working from Veeam's own port references (KB4626 and the VBR Kasten Integration Guide), the requirement breaks down into two logical targets, which may or may not be the same physical host:
| Target | Port(s) | Purpose |
|---|---|---|
| VBR server | 9419 | VBR REST API |
| VBR server | 443 | VBR 13+: OAuth2 component fetches a certificate to authenticate API access |
| Repository | 10006 | vmb api port (datamover → repository) |
| Repository | 6162 | veeamtransport (repository management) |
| Repository | 2500–3300 | veeamagent data transfer — dynamic, only active during a running job |
That last row trips people up. If you scan that range with no backup or restore in flight, every port comes back closed — and that's correct, not a fault. It's tempting to read an all-closed range as a firewall problem and go chasing ACLs that don't exist. Worth calling out explicitly in any handoff so nobody burns an afternoon on it.
The first version, and the trap I fell into
The first cut of the script leaned on bash's /dev/tcp pseudo-device — a handy built-in for TCP checks without needing extra tooling. It worked fine in testing. Then it went to a real RKE2 node and reported every single port closed, including ones with zero ACLs in the way.
The cause: /dev/tcp isn't guaranteed to be compiled into bash. A number of RHEL/Rocky-family builds ship bash with net redirections disabled entirely, for hardening reasons. When that's the case, every check fails identically regardless of actual reachability — which is a particularly unhelpful failure mode, because it looks exactly like a real network problem.
The fix was to stop depending on any single method. The script now checks what's available on the node and picks the best option, in order: nc, then python3, then /dev/tcp as a last resort — and it prints which one it picked, so a false negative like that is immediately obvious rather than silently misleading.
if command -v nc >/dev/null 2>&1; then
METHOD="nc"
elif command -v python3 >/dev/null 2>&1; then
METHOD="python3"
else
METHOD="devtcp"
fi
Scanning 800 ports without taking all day
The 2500–3300 range is 801 ports. Checking those sequentially, even with a short timeout, adds up fast — potentially 15–25 minutes per node depending on timeout settings. That's not something you want to run across a full worker fleet one node at a time.
Parallelising the range scan with xargs -P brings that down to roughly a minute:
RESULTS=$(seq 2500 3300 | xargs -P "$PARALLEL" -I{} bash -c "check_port '$REPO_HOST' {}")
Timeout and parallelism are both exposed as arguments, so the balance between speed and network path latency can be tuned per environment rather than hardcoded.
Taking it from "SSH into every node" to "run once, cluster-wide"
Running the script by hand over SSH works fine for a handful of nodes. It stops being practical the moment you're dealing with a real production cluster with a dozen-plus workers. The natural next step was containerizing it and running it as a Kubernetes DaemonSet — one pod per worker node, same script, no manual SSH loop.
A couple of details mattered here:
hostNetwork: true is non-negotiable. Without it, the pod tests connectivity through the CNI/pod network, not the actual node network path — which defeats the entire point of the exercise. The whole reason to run this per-node is to catch node-specific network differences (subnet, NIC, local firewall), and the pod network abstracts exactly that away.
No tolerations, on purpose. RKE2 control-plane nodes carry a NoSchedule taint by default, so a DaemonSet with no tolerations added naturally schedules on worker nodes only — no need for explicit node selectors in the common case. Worth flagging in documentation for anyone running this against a cluster with combined master/worker nodes, where that assumption won't hold.
Sleep instead of exit. Left to its own devices, a completed check would exit, and a DaemonSet pod exiting means Kubernetes restarts it — repeating the check in a loop indefinitely. Since this is a one-shot diagnostic, not a long-running service, the entrypoint runs the check once and then sleeps, so the pod stays Running and results stay available via kubectl logs until you're done with them.
ENTRYPOINT ["/bin/bash", "-c", "/usr/local/bin/check-vbr-ports.sh \"$@\"; sleep infinity", "--"]
The registry detour
Getting the image built was the easy part. Getting it pushed somewhere was its own small adventure — a good reminder that docker push doesn't rename anything for you. If the local tag doesn't already match the destination repository path exactly, you'll get one of a few unhelpful-looking errors depending on where it fails:
- Pushing to
library/<image>implicitly (i.e. no namespace prefix) hits Docker Hub's restriction that thelibrary/namespace is reserved for official images —insufficient_scope. - Pushing to a namespace/tag combination that was never actually created locally with
docker tag—tag does not exist. - Pushing to an internal Harbor registry over a hostname that doesn't match the certificate's SAN — a
x509: certificate is valid for *.host, not ui.hostmismatch, in this case because the URL used for browser access to the Harbor UI (with aui.prefix) wasn't the same as the registry API endpoint the wildcard cert actually covered.
None of these are exotic problems, but they're exactly the kind of thing that eats twenty minutes if you don't immediately recognize the pattern: tag mismatch, missing project, or hostname-vs-cert mismatch, roughly in that order of likelihood.
Where it landed
The end result is two ways to run the same check, both driven by the identical script so there's no drift between them:
- Direct, for quick one-off checks on a single node:
./check-vbr-ports.sh <vbr_host> <repo_host> - DaemonSet, for validating an entire cluster in one pass: build the image, push it to a registry,
kubectl applythe manifest, and pull results withkubectl logs -l app=vbr-port-check --all-containers
Neither approach depends on installing anything extra on the target nodes — the script auto-detects tooling, and the container ships its own. For pre-sales and implementation work where the same check needs to run across different customer environments with wildly different node OS images and hardening postures, that portability ends up mattering more than it seems like it would at the outset.