Termix Proxmox Sync
Generates a Termix JSON import file from the Proxmox dynamic inventory, so every VM and LXC shows up in Termix without being added by hand.
Hosts are emitted with authType: "credential" and reference credential IDs that
already exist in Termix. No secret ever passes through this script — the generated
JSON contains hostnames, IPs, tags and numeric credential references, nothing more.
Why not Termix’s built-in Proxmox import
Section titled “Why not Termix’s built-in Proxmox import”Termix ships a native Proxmox import: it SSHes to your PVE node, runs pvesh, and lets
you pick guests to import. It’s good, and worth using for a one-off.
This script exists for the cases it doesn’t cover:
- It reuses
ansible/inventory/proxmox.proxmox.yml, so the IP for each guest comes from the same logic Ansible uses — the LXCeth0/ QEMU guest-agent interface resolution already encoded there. One definition, not two. - Your Proxmox tags become Termix tags, and drive per-host settings through rules (a
docker-tagged guest gets the Docker integration turned on). - It re-runs. Regenerate after adding guests and re-import, rather than hand-picking from a list each time.
The built-in import guesses SSH-vs-RDP from the guest name and folders by node. This script always emits SSH and folders by node, which keeps the two consistent if you use both.
pip install -r requirements.txtcp config.example.yml config.ymlThen edit config.yml and set the credential IDs. Find an ID in Termix under
Credentials → open the credential → the ID: text in the viewer.
The inventory plugin needs Proxmox credentials in the environment, the same ones the Ansible playbooks use:
export $(cat ../../ansible/.env | grep -v '^#' | xargs)./termix_proxmox_sync.py -c config.yml -o termix-hosts.jsonEnvironment variables
Section titled “Environment variables”| Variable | Needed for | Notes |
|---|---|---|
PROXMOX_URL | inventory | Required — see the caveat below |
PROXMOX_TOKEN_SECRET | inventory | Required |
TERMIX_API_KEY | --push only | Starts with tmx_ |
PROXMOX_URL looks optional in proxmox.proxmox.yml because of a
| default('http://proxmox.local:8006'), but that fallback never fires:
lookup('env', ...) returns an empty string for an unset variable, and Jinja’s
default() only substitutes for undefined values, not empty ones. Unset it and the
URL is empty, not the default. (Fixable upstream with | default('...', true).) The
script warns when either variable is missing.
PROXMOX_USER and PROXMOX_PASSWORD appear in ansible/.env.example but this
inventory ignores both — it hardcodes user: root@pam and token_id: ansible, so the
API token must be root@pam!ansible.
Then in Termix: Host Manager → import → upload termix-hosts.json.
Useful flags:
| Flag | Effect |
|---|---|
-o - | Write JSON to stdout instead of a file |
--dry-run | Print the summary, write nothing |
--from-json FILE | Read a saved ansible-inventory --list dump instead of querying Proxmox |
--ansible-dir DIR | Point at a different checkout (default: ../../ansible) |
--inventory PATH | Use a different inventory file |
--from-json is handy offline, and for re-rendering after a config change without
hitting the Proxmox API again:
cd ../../ansible && ansible-inventory -i inventory/proxmox.proxmox.yml --list > /tmp/inv.jsoncd - && ./termix_proxmox_sync.py -c config.yml --from-json /tmp/inv.json -o -A run summary goes to stderr, so -o - stays pipeable:
warning: skipped (excluded): pve-dellwarning: skipped (stopped): semaphorewarning: skipped (no IP from Proxmox): talos-plane-114 host(s) across folder(s): pve, pve2Configuration
Section titled “Configuration”See config.example.yml for the annotated version. The shape:
default_credential_id: 1 # "pve" / rootdefault_username: rootinclude_stopped: falseskip_unresolved: trueexclude: [pve-dell]exclude_tags: [community-script]
defaults: # applied to every host enable_terminal: true enable_file_manager: true
overrides: # matching rules, applied in order - match: { tag: docker } enable_docker: true - match: { hostname: traefik } pin: true notes: Reverse proxy — recreate via `bws run` only.Every matching rule applies, in order, and later rules win on the fields they set.
So in the example above traefik — which is both tagged docker and named in the
second rule — ends up with enableDocker: true and pin: true. Rules layer rather
than replace, which is what lets a broad tag rule and a narrow hostname rule coexist,
and it is how one host gets a different credential_id from the rest.
Only reference credential IDs that exist. A host pointing at a missing credential is still created and then silently fails to connect. List the real ones with:
curl -H "Authorization: Bearer $TERMIX_API_KEY" http://192.168.1.102:8080/credentialsMatch keys: hostname, tag, node, type (lxc or qemu). hostname and tag
also accept a list. Unknown keys, unknown rule fields, and typos in defaults all
produce a warning rather than failing silently.
Settable fields: credential_id, username, override_credential_username, port,
folder, default_path, notes, pin, tags_extra, enable_terminal,
enable_tunnel, enable_file_manager, enable_docker, and the five
show_*_in_sidebar toggles.
Behaviour worth knowing
Section titled “Behaviour worth knowing”Ansible’s unsafe-string wrapping is undone on load. Facts the Proxmox inventory
plugin marks unsafe — which is most of them — serialise as
{"__ansible_unsafe": "running"} rather than "running". Left wrapped, every
comparison fails silently: every guest looks stopped, types look unknown, and tags
become dicts. Both input paths unwrap recursively before anything else runs.
Stopped guests are skipped by default (include_stopped: true to keep them). LXC
status comes from proxmox_status, QEMU from proxmox_qmpstatus — the inventory
reports them under different keys.
IP-address tags are stripped automatically. The ProxmoxVE community-script
installers record each guest’s addresses as Proxmox tags, so a typical guest carries
172.17.0.1;172.18.0.1;192.168.1.175;community-script;docker. Anything that parses as
an IP is dropped; use exclude_tags for name-based noise like community-script.
Guests that share a name collapse. Ansible keys hostvars by name, so two Proxmox
guests called sonarr become one entry and the other is lost before this script sees it
— it cannot detect or warn about this. Rename one in Proxmox if both matter.
Guests with no resolvable IP are skipped by default. proxmox.proxmox.yml falls
back to the inventory hostname when neither the QEMU guest agent nor the LXC interface
list reports an address, which would produce a Termix host that cannot connect. Set
skip_unresolved: false to emit them anyway and let internal DNS resolve the name.
Either way the affected hosts are named in a warning.
username is cosmetic unless you override. With authType: "credential", Termix
uses the credential’s own username and ignores the host’s, unless
override_credential_username: true.
Re-running is not a diff. The script always emits the full desired state.
--overwrite is what makes a re-run idempotent: it matches on ip:port:username and
updates in place. Without it a re-run creates duplicates. Note the match key is the
address, not the name — renaming a guest updates the existing host, but re-addressing
one creates a second host under the same name, which needs a manual tidy-up.
Nothing here ever deletes a host that has disappeared from Proxmox — prune those in the Termix UI.
Pushing directly to Termix
Section titled “Pushing directly to Termix”--push POSTs the hosts to Termix instead of leaving you to upload a file by hand.
Create an API key in Termix under Admin Settings → API Keys (tokens start with
tmx_), then:
export TERMIX_API_KEY=tmx_your_token_here./termix_proxmox_sync.py -c config.yml --push --termix-url http://192.168.1.102:8080Set termix_url in config.yml to skip the flag. The API key is only ever read from
the environment — there is no config field for it, so no token lands in git.
| Flag | Effect |
|---|---|
--push | POST to Termix (still writes the JSON file as well) |
--no-file | With --push, skip writing the file |
--overwrite | Update hosts that already exist instead of skipping them |
--termix-url URL | Base URL, e.g. http://192.168.1.102:8080 |
--insecure | Skip TLS verification, for internal step-CA certs |
--timeout N | Per-request seconds (default 60) |
--endpoint-path PATH | Override the route (default /host/bulk-import) |
--dry-run works with --push too — it reports how many hosts and requests it would
send without sending anything.
Things the API imposes
Section titled “Things the API imposes”Termix caps each request at 100 hosts, so the script splits larger fleets into chunks automatically and aggregates the totals:
pushing chunk 1/3 (100 hosts)...pushing chunk 2/3 (100 hosts)...pushing chunk 3/3 (50 hosts)...pushed to http://192.168.1.102:8080/host/bulk-import: 250 created, 0 updated, 0 skipped, 0 failedUse --overwrite on every run after the first. Termix only builds its existing-host
lookup when overwrite is set — without it every host takes the create branch, so a
second push duplicates the first rather than skipping it. Verified against a live
instance: two identical pushes with overwrite:false produced two hosts; a third with
overwrite:true reported 0 created, 1 updated. The script warns before pushing
without it.
The skipped counter in the response is never incremented by this endpoint. It is
always 0 and means nothing.
The script exits non-zero if Termix reports any failures, which makes it safe to run from cron or a CI job.
About the endpoint path
Section titled “About the endpoint path”The route is POST /host/bulk-import. Three sources agree: the OpenAPI spec
(static/openapi.json in Termix-SSH/Docs), the server code (the host router is
mounted at /host, the route registered as /bulk-import), and the live API — an empty
hosts array returns 400 Hosts array is required and must not be empty, which proves
the route resolves.
The published reference page at docs.termix.site/api/bulk-import-ssh-hosts displays
POST http://localhost:30001/ssh/bulk-import. That page is wrong.
/ssh/bulk-import appears nowhere in openapi.json, and posting to it returns
405 Not Allowed from nginx — the /ssh/ prefix is served as frontend routing, so the
request never reaches the API. It looks like a stale generated artifact; /ssh/* in the
spec proper is tunnel and file-manager territory.
--endpoint-path exists in case a future version genuinely moves the route. A 404 or
405 from the push points at it in the error message.