The shell module
The shell module lets a node run commands that its operator has approved, on request of
callers that the operator has listed. A typical use is a development board or a remote
machine on which a service has to be restarted, a log read or a firmware flashed from the
other end of the network, with the output returned to the caller and the permission
revocable at any time.
The module is the only one that turns a request from the network into a command on the host.
It is therefore additive: it is not part of the default build, and it is included only in
the build with the tag shell (install.sh --shell, or ./build.sh --with shell). See
Modules.
Three separate decisions
Whether a node runs remote commands depends on three decisions, each of which is made on purpose.
The build. A binary without the tag
shellcontains no code for it. A build with the tag registers no capability and runs no command until the second decision is made.The configuration. The module acts only when
config.jsonhas a blockmodules.shellthat defines at least one command or setsallow_arbitrary. A binary that contains the module and has no block is inactive.The list of callers. A block with commands and an empty list of callers refuses every call. An empty list means nobody, not everybody.
A caller must also pass the inbound policy of the node. The default policy is closed, so
the caller has to be on peers.allow (anet peers allow <aid>) before a call reaches the
module. The two lists have different tasks: peers.allow decides who may hand tasks to the
machine, and the list of the module decides which of them may run commands. Do not list a
shell.* capability in inbound.public_capabilities. That would admit strangers and leave
the list of the module as the only barrier.
A call without the identity of a caller is refused unless allow_local is true. The
empty value of the identity must not mean “local and trusted”, because a call site that omits
the identity would then bypass the whole list without a trace.
Configuration
{
"modules": {
"shell": {
"commands": {
"restart-app": {"run": "systemctl restart myapp", "description": "Restart the application"},
"tail-log": {"run": "journalctl -u myapp -n 200 --no-pager"},
"flash": {"run": "/opt/tools/flash.sh", "args": true, "timeout_s": 600}
},
"allow_file": "/etc/anet/shell-allow",
"timeout_s": 30,
"max_output_bytes": 65536
}
}
}
Key |
Meaning |
|---|---|
|
The command line. It is run through the interpreter, so that pipes, redirections and
|
|
A description for the capability list. |
|
|
|
The working directory. |
|
The time limit of this command. It overrides the global value. |
|
The AIDs that may call, as an inline list. It is read at the start. |
|
A file with the AIDs that may call, one for each line; |
|
Admits calls without the identity of a caller. The default is |
|
Offers |
|
The global time limit in seconds. The default is 30 and the largest value is 86,400 (24 hours). |
|
The largest size of the output that is returned. The default is 65,536 and the largest value is 4 MiB. |
|
The interpreter. The default is |
The node writes a warning to standard error at the start when no caller is allowed, and when
allow_arbitrary is on.
Capabilities
Capability |
Behavior |
|---|---|
|
Reports the commands that the node defines and the number of callers on the list. A remote caller receives only the number, not the AIDs, so that an accepted caller does not learn which other machines the node trusts. A local call receives the full list. |
|
Runs one defined command. |
|
Runs the command that the caller sends. It exists only while |
The names of the commands become capability identifiers such as shell.run@restart-app,
and a node announces the capabilities that it answers. Anyone who can browse the directory of
a hub can therefore see which commands the machine offers. The list of callers still guards
them. A command whose existence should not be known has a name that carries no information.
Revoking access
The file named by allow_file is read again before every call and is not cached. Deleting
a line, or the whole file, takes effect with the next call and needs no restart. A missing file
is an empty list, which refuses everybody. It is neither an error nor permission for all. The
inline list allow changes only when the configuration is loaded again, so a production
node should use allow_file.
Behavior
No privilege escalation. A command runs as the user of the daemon. A command that needs root requires a daemon that runs as root, which is a decision of the installation and is visible in the unit file. The module calls neither
sudonorsetuid.Arguments do not become commands. With
args: true, each argument of the caller is quoted and appended. The command line belongs to the operator, the arguments to the network, and the two are never mixed.The time limit ends the process group. Ending only the shell would leave the processes that it started on the machine.
The output is bounded. A longer output is cut, and the output states the cut.
The effect status is accurate. Exit status 0 is
OK. Any other status isFAILEDwith the exit status and the standard error. A command that is killed after the time limit isFAILED, and the message states that it was killed.Every run is on the evidence chain. The event
anet.shell.commandrecords the caller, the command, the exit status and the duration. A refused call is recorded asanet.shell.refused, with the AID of the caller. The output is not part of the chain.
The line that a run contains is not restricted. The operator who writes it is trusted, and
the trust boundary lies between what the operator wrote and what the network sent.