Keep a Warpgate bastion in sync
with an Odoo database — and let its users get their
ssh config from the same place.
Two people use this tool, and this document is written for both:
- the manager who runs the bastion and wants Odoo, not hand-edited YAML, to be the source of truth for which machines exist and who may reach them — see For the manager;
- the Odoo user who just wants
ssh some-machineto work, without ever touching Warpgate — see For the Odoo user.
Odoo is optional: warpgate-man is at bottom a declarative manager that
reconciles one or more Warpgate servers to a YAML file, and the Odoo source
is one way of filling that file in. The Reference section covers
the general form.
warpgate-man is a command-line tool — install it as one. It and its
oerpc dependency are fetched from git, so git must be available.
uv tool install 'warpgate-man[odoo] @ git+https://github.com/0k/warpgate-man.git'That puts warpgate-man in ~/.local/bin, in an isolated environment of
its own: no virtualenv to create or activate. (uv pip install is the
wrong verb here — it installs into the virtualenv you are currently in,
and refuses to guess one.)
From nothing at all, on a bare Debian-based image:
apt-get update && apt-get install -y curl git
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env" # puts uv, and ~/.local/bin, on PATH
uv tool install 'warpgate-man[odoo] @ git+https://github.com/0k/warpgate-man.git'The env line matters: uv’s installer does not touch the PATH of the
shell that ran it, so uv is otherwise not found until you open a new one.
To run it once without installing anything:
uvx --from 'warpgate-man[odoo] @ git+https://github.com/0k/warpgate-man.git' \
warpgate-man --versionWithout uv, pip takes the same requirement string:
pip install 'warpgate-man[odoo] @ git+https://github.com/0k/warpgate-man.git'The odoo extra pulls in the =oerpc=
JSON-RPC client. Both personas need it: the manager to read Odoo, the user
to authenticate to it. Without the extra everything else works, and any
Odoo command tells you to install it.
From a checkout:
uv sync --extra odoo # add --extra dev for the test suite
uv run warpgate-man --versionYou run a Warpgate bastion in front of a fleet of machines, and Odoo already
knows those machines and the people who work on them. Rather than repeating
that knowledge in Warpgate by hand, warpgate-man reads it from Odoo and
reconciles the bastion to match: creating what is missing, updating what
differs, and — only when you ask — deleting what is gone.
Three things are read. The first two come with the
maintenance_server_data addon; the third is what makes access work at all,
and it is the one your users have to supply themselves.
- Machines are
maintenance.equipmentrecords whosessh_targetfield is set. That field is the address the bastion dials, in the form[user@]HOST[:PORT]—root@ceres.example.com:22, defaulting torootand22. The record’snamebecomes the target name your users will type afterssh, so keep it short and stable. - People are
res.users. A person’s Odoologinbecomes their Warpgate username, verbatim. Which accounts become bastion users is up to your selection (below). - SSH public keys are
ssh.keyrecords attached to a user (user_id+key). Every user must have added their public key to their Odoo profile before they can log in through the bastion. Warpgate authenticates by public key: a user with no key in Odoo gets an account on the bastion but cannot open a session with it.Keys pasted with hard line-wraps are repaired at read time. Anything that is not an OpenSSH public key stops the run with a loud error naming the record, rather than being uploaded as a credential nobody can use.
An admin token to write with. Start Warpgate with --enable-admin-token
and set WARPGATE_ADMIN_TOKEN on the server, or mint a personal API token
for an admin user in the web UI. Either is passed as api-key below and
never written in clear text — reference an environment variable.
One file, say wgman.yaml:
servers:
- name: prod
url: https://warpgate.example.com:8888
api-key: ${WG_PROD_TOKEN}
ssh-host: warpgate.example.com # what your users' ssh will dial
ssh-port: 2222 # optional, default 2222
odoo:
url: https://odoo.example.com
db: mydb # optional; see below
user: sync@example.com
password: ${ODOO_PASSWORD} # omit to be prompted interactively
# Which machines become targets, and which role reaches them.
targets:
domain: [["ssh_target", "!=", false]] # the default
roles: [sysadmin]
# Which Odoo accounts become bastion users, and with which roles.
users:
- domain: [["groups_id", "in", [12]]] # e.g. an Odoo "sysadmins" group
roles: [sysadmin]
- domain: [["login", "=", "alice@example.com"]]
roles: [developers]Access in Warpgate is a user and a target share a role. Naming the same
role on a targets: entry and a users: entry is the entire wiring; every
role mentioned is declared for you. Leave roles out everywhere and no
access is granted — every user is created, every machine is created, and
nobody reaches anything, which is rarely what you want.
ssh-host / ssh-port are not needed to sync. They exist so your users can
build their ssh config from this same file without knowing the
bastion’s address by heart.
Selections are raw Odoo domains — the [field, operator, value] triplets
(with |, &, !) you would give search_read, forwarded verbatim.
Anything Odoo can express is available.
Preview what Odoo yields, without touching anything:
warpgate-man --config wgman.yaml fetchIt prints the targets, users, keys and roles as YAML in exactly the shape of
a config file, so you can read it or diff it against a previous run. Because
it never contacts Warpgate, an unset ${WG_PROD_TOKEN} does not stop it.
See what would change on the bastion:
warpgate-man --config wgman.yaml diffApply it:
warpgate-man --config wgman.yaml applyapply creates and updates. It deletes nothing unless you say so:
warpgate-man --config wgman.yaml apply --prunewhich removes targets no longer in Odoo — and only targets, by default. Users, roles and groups stay put, because a selection that happens to match nobody must not wipe the admin account. Widen this deliberately in the prune section.
Keys are synchronised strictly, in one direction: a key removed from Odoo is removed from the bastion. A user with no key in Odoo is left untouched, so an incomplete Odoo does not lock anyone out.
There is deliberately no --odoo-password flag: a password on the command
line leaks into ps and shell history. Use ${ODOO_PASSWORD} in the file,
or leave the key out and type it at the prompt.
You have an Odoo account, and someone has told you the machines you work on
now sit behind a bastion. You need two things: your key on file, and an
ssh config. You never need to log into Warpgate itself.
In your Odoo profile, add your SSH public key (the contents of
~/.ssh/id_ed25519.pub or similar) as an ssh.key record. The manager’s
next sync pushes it to the bastion. Without it, the bastion knows your name
but has no way to let you in.
Install warpgate-man (see Installation), then:
warpgate-man --odoo-url https://odoo.example.com --odoo-db mydb \
--odoo-user you@example.com \
ssh-config --from-odoo --bastion warpgate.example.com:2222 \
-o ~/.ssh/config.d/warpgateIt asks for your Odoo password, and writes something like:
# warpgate targets for you@example.com via warpgate.example.com:2222
# generated from https://odoo.example.com (desired state) — run 'apply' first if the bastion is not up to date
Host ceres-prod
HostName warpgate.example.com
Port 2222
User you@example.com:ceres-prod
Host web-01
HostName warpgate.example.com
Port 2222
User you@example.com:web-01Then, once, in ~/.ssh/config:
Include config.d/warpgateand ssh ceres-prod works. Re-run the command whenever the fleet changes.
If your manager gave you a wgman.yaml with the odoo: and ssh-host
details filled in, the whole thing shortens to:
warpgate-man --config wgman.yaml ssh-config --from-odoo -o ~/.ssh/config.d/warpgateThe file’s api-key is not read on this path, so you need no Warpgate
token and no ${WG_PROD_TOKEN} in your environment — that is the point.
The key may be left out of the file altogether: a server entry needs only
name, url and ssh-host to serve this command.
Generating an ssh config is a once-in-a-while errand, so you do not have to
install anything for it. uvx fetches warpgate-man, runs it, and keeps
nothing:
uvx --from 'warpgate-man[odoo] @ git+https://github.com/0k/warpgate-man.git' \
warpgate-man --odoo-url https://odoo.example.com --odoo-db mydb \
--odoo-user you@example.com \
ssh-config --from-odoo --bastion warpgate.example.com:2222 \
-o ~/.ssh/config.d/warpgateIt prompts for your Odoo password and writes the same file as above. All
you need on the machine is uv and git (see Installation) — no
virtualenv, no config file, and no Warpgate token.
The three --odoo-* flags are what replace the config file here, so give
all three; with only some of them warpgate-man looks for a wgman.yaml
and stops when there is none.
odoo-get-ssh-config does all of the above on a machine that has nothing
installed: it gets uv if it is missing, asks once for your Odoo server
and your bastion, and prints the ssh config.
curl -LsSfO https://raw.githubusercontent.com/0k/warpgate-man/master/scripts/odoo-get-ssh-config
chmod +x odoo-get-ssh-config
mv odoo-get-ssh-config ~/.local/bin/odoo-get-ssh-config > ~/.ssh/config.d/warpgateIt asks what it needs on the first run and remembers it, so later runs
ask only for your Odoo password. Only the ssh config goes to stdout, so
that redirection yields exactly the file ssh wants. Run --help for
the options.
Your ssh client only ever talks to the bastion. The machine you want is
named inside the User field, as <your-login>:<target>; the bastion reads
it, checks that one of your roles grants that target, and dials the machine
itself with its own credentials. So:
- your Odoo login is your bastion identity — nothing to configure;
- the machines’ real addresses never appear in your config, and you never need to know them;
known_hostspins the bastion, not each machine, and nothing here weakens that check.
Which targets you get is the rule the bastion applies: those sharing a role
with you. It is computed from what the manager’s configuration declares, so
it describes what the bastion looks like after their last apply. A
machine added to Odoo this morning appears in your config but does not
answer until the sync has run. And if that configuration declares no roles
at all, it is not describing access, so every machine is listed — whether
each one answers is then up to how the bastion was set up.
Use --prefix wg- if a target name collides with a host you already have
(ssh wg-web-01).
Everything above is one way to use warpgate-man. Underneath, it is a
declarative manager for any Warpgate server, Odoo or not.
Warpgate keeps targets, users and roles in its database, reachable through
its HTTP admin API (/@warpgate/admin/api). warpgate-man reads your
desired state, reads the live state, and issues the creates, updates and
(with --prune) deletes that make them match. Matching is by name;
Warpgate’s own ids are never in your file.
The desired state has two sources, usable together: the YAML file’s own
target-groups: / targets: / roles: / users: sections, and the Odoo
server named in odoo:. A name defined on both sides is an error, not a
silent override.
See examples/wgman.yaml for a complete annotated file. In short:
servers:
- name: prod
url: https://warpgate.example.com:8888
api-key: ${WG_PROD_TOKEN}
verify-tls: true # optional, default true
ssh-host: warpgate.example.com # optional, for ssh-config --from-odoo
ssh-port: 2222 # optional, default 2222
roles:
- name: admin
- name: developers
default: true # auto-assigned to every user
target-groups: # purely organisational (UI folders)
- name: databases
color: info
targets:
- name: pg-main
kind: ssh # ssh | http | mysql | postgres | kubernetes
host: 10.0.0.5
port: 22
username: postgres
auth: publickey # publickey | password | iam_role
roles: [admin]
targets: # ungrouped
- name: app-frontend
kind: http
url: http://10.0.1.10:8080
roles: [developers]
users:
- name: alice
roles: [admin]
public-keys:
- ssh-ed25519 AAAAC3Nz... alice@laptopSecrets go through ${VAR} and are interpolated from the environment at
load time. Never write one in clear text.
| Odoo | Selection | Becomes |
|---|---|---|
maintenance.equipment | targets.domain | SSH target; ssh_target gives host/port/account, name the target name |
res.users | each users[].domain | one user per login; roles accumulate across entries |
ssh.key | those users’ keys | the user’s public-key credentials |
| roles named above | — | declared automatically |
Only SSH targets come from Odoo: it describes machines, not HTTP or database endpoints. Add those in the YAML.
--odoo-url, --odoo-db and --odoo-user override the odoo: section.
--odoo-url and --odoo-user are enough to run fetch or ssh-config
--from-odoo with no config file at all.
db may be left out, in the config and on the command line alike. Odoo
publishes its database list on the same unauthenticated endpoint its own
login page uses, so warpgate-man asks before logging in:
- exactly one database — it is used, and nothing is asked;
- several — an error listing them, because picking one would silently work against the wrong data;
- listing disabled (
list_db = False, common hardening) — an error saying to name it explicitly.
Declaring db skips the lookup entirely, so it also serves as the answer
to either error.
--prune is the master switch; the optional prune: section says what it
may delete. Without it, only targets are pruned.
prune:
targets: true # default true
target-groups: false # default false
roles: false # default false
users: false # default false
keep-users: [admin] # never deleted, even when users are pruned
keep-roles: [admin]Each flag turns deletion on or off for one kind; each keep-* list protects
individual names even when that kind is pruned.
api-key is your Warpgate token, and what you may run follows from what it
is:
- an admin token (
--enable-admin-token+WARPGATE_ADMIN_TOKENon the server, or a personal token of an admin user) is needed bydiffandapply; - a personal API token (web UI → your profile → API tokens) identifies you
as a user, and is what the plain
ssh-configneeds; ssh-config --from-odooneeds no Warpgate token at all.
Accordingly api-key is optional in the file: it is what authenticates
toward the bastion, not what identifies a server, so a config written for
an end user may declare a server by name, url and ssh-host alone. A
command that does authenticate then refuses with a message naming the
server and how to fix it, rather than sending an empty token.
A personal token used with apply fails with a message saying what it
lacks, not a bare HTTP 403. The server’s global admin token is bound to no
user: plain ssh-config refuses it rather than emitting an empty file.
If you hold a personal Warpgate token, the bastion can answer directly which targets you reach:
warpgate-man --config wgman.yaml ssh-config -o ~/.ssh/config.d/warpgateThis reads the live state — exactly what Warpgate grants right now — and takes the bastion address from the server itself, honouring reverse-proxy and NAT settings. When several servers are configured, aliases are prefixed with the server name, since target names are only unique within one server.
The two forms emit the same stanzas; they differ only in who they ask and what they need to ask it.
Warpgate’s SSO is a browser-redirect flow with no headless variant, so
warpgate-man cannot log in through SSO itself. SSO users either mint a
personal token in the web UI, or — simpler — use --from-odoo, which never
touches Warpgate.
Everything the CLI does is available from Python, synchronous and dependency-light, for calling from another program (Odoo included).
from wgman import WarpgateManager, Target, Role, User
mgr = WarpgateManager(url="https://warpgate.example.com:8888",
api_key="...")
plan = mgr.reconcile(
roles=[Role(name="admin")],
targets=[Target(name="pg-main", kind="ssh", host="10.0.0.5",
username="postgres", auth="publickey",
roles=["admin"])],
users=[User(name="alice", roles=["admin"])],
prune=False,
)
print(plan) # what was created / updated / deletedOr from a YAML file:
from wgman import load_config, WarpgateManager
config = load_config("wgman.yaml")
mgr = WarpgateManager.from_server(config.server("prod"))
mgr.reconcile_config(config, prune=False)The user side too. From the live bastion, with a personal token:
from wgman import WarpgateUserClient, sshconfig
from wgman.sshconfig import BastionInfo
with WarpgateUserClient("https://warpgate.example.com:8888", "...") as client:
info = BastionInfo.from_info(client.get_info(),
url="https://warpgate.example.com:8888")
print(sshconfig.render(client.list_targets(), info))Or from the desired state, with no bastion credential — the same rule
--from-odoo applies, as wgman.access:
from wgman import access, sshconfig
from wgman.sshconfig import BastionInfo
me = next(u for u in config.users if u.name == "alice@example.com")
mine = access.reachable_targets(
me, access.ssh_targets(config.all_targets()), config.roles)
info = BastionInfo.declared(username=me.name,
host="warpgate.example.com", port=2222)
print(sshconfig.render([access.to_render_payload(t) for t in mine], info))MIT.