NAME
POE::Component::Server::JSONUnix - pluggable JSON-over-Unix-socket server for POE
SYNOPSIS
use POE;
use POE::Component::Server::JSONUnix;
my $server = POE::Component::Server::JSONUnix->spawn(
socket_path => '/tmp/app.sock',
socket_mode => 0600,
commands => {
echo => sub {
my ($server, $request, $ctx) = @_;
return { echoed => $request->{args} };
},
},
);
# Add more commands at any time.
$server->register(
add => sub {
my ($server, $request, $ctx) = @_;
my $sum = 0;
$sum += $_ for @{ $request->{args}{numbers} // [] };
return { sum => $sum };
},
);
$poe_kernel->run;
DESCRIPTION
This module is a small, event-driven server that listens on a Unix domain socket and speaks a simple JSON request/response protocol. It is built on POE and is designed to be extended: the set of commands it understands is a plain dispatch table you can add to at construction time, at run time, or by subclassing.
It is suitable as a local control or RPC endpoint for a daemon -- the sort of thing you talk to from a command-line tool, a cron job, or another process on the same host.
PROTOCOL
The framing is newline-delimited JSON: each message is a single JSON object on its own line, terminated by \n.
A request looks like:
{"command":"add","args":{"numbers":[1,2,3]},"id":7}
command(required) -- the name of the command to run.cmdis accepted as an alias.args(optional) -- an arbitrary payload passed straight through to the handler.id(optional) -- an opaque value echoed back in the response so asynchronous clients can correlate replies with requests.
A successful response:
{"id":7,"status":"ok","result":{"sum":6}}
An error response:
{"id":7,"status":"error","error":"unknown command: subtract"}
Malformed JSON, a non-object request, a missing command, an unknown command, or a handler that dies all produce an error response rather than disturbing the server or other clients.
CONSTRUCTOR
spawn
my $server = POE::Component::Server::JSONUnix->spawn(%args);
Creates the server's POE session and returns the server object. Recognised arguments:
socket_path
Required. Filesystem path of the Unix domain socket to listen on. If a stale socket file is present it is removed; if another process is actively listening there, spawn dies rather than clobber it.
commands
Hash reference of name => \&handler pairs to register. See "COMMAND HANDLERS".
socket_mode
If set (e.g. 0600), chmod the socket to these permissions after binding. Unix socket permissions govern who may connect, so setting this is recommended.
alias
POE session alias. Defaults to json_unix_server. Set this if you run more than one server in a single process.
unlink_existing
Whether to remove a stale (not-in-use) socket file on startup. Defaults to true.
on_error
Code reference called as $cb->($operation, $errnum, $errstr [, $wheel_id]) on listen and connection I/O errors. Normal client disconnects are not reported.
auth_temp_dir
Directory used for the cookie-file ownership challenge. Defaults to File::Spec->tmpdir (usually /tmp).
auth_required
If set to a true value, all commands except auth_start and auth_verify return an error until the client has successfully completed the ownership challenge. Defaults to false.
permissions
Optional user/group permission policy, a hash reference of the form {default => 'allow'|'deny', commands => {name => $spec, ...}}. When this argument is not given the server behaves exactly as it does without the feature. See "PERMISSIONS".
METHODS
register
$server->register(name => \&handler, ...);
Add or replace commands. Returns the server object. Croaks if a handler is not a code reference.
command_names
my $names = $server->command_names; # array reference, sorted
The names of all currently registered commands. (Also available to clients as the built-in commands command.)
shutdown
$server->shutdown;
Stop accepting connections, close all clients, remove the socket file, and let the session end.
COMMAND HANDLERS
A handler is a code reference called as:
$handler->($server, $request, $ctx)
where $request is the decoded request hash and $ctx is a context object (see "THE CONTEXT OBJECT"). A handler answers in one of three ways:
- Synchronously
-
Return a value. It is wrapped and sent as
{status => 'ok', result => $value}. - By raising an error
-
diewith a string (sent as{status => 'error', error => $string}, with the trailing "at FILE line N" trimmed) or with a hash reference (merged into the error response). - Asynchronously
-
Return
undef, stash$ctxsomewhere, and call$ctx->respond_result(...)(or$ctx->error(...)) later -- for example after a timer fires or a backend request completes.
ADDING COMMANDS
Commands can be registered three ways. When names collide, later wins, in this order: built-ins, then cmd_* methods, then the commands argument and register.
1. At construction
POE::Component::Server::JSONUnix->spawn(
socket_path => $path,
commands => { hello => sub { ... } },
);
2. With register
$server->register(name => sub { ... }, name2 => sub { ... });
From inside another POE session you can instead post to the server's alias:
$poe_kernel->post($alias => register_command => $name => \&handler);
3. By subclassing
Any method named cmd_<name> anywhere in the class hierarchy is discovered automatically and exposed as a command. It is invoked as $server->cmd_name($request, $ctx).
package MyApp::Server;
use parent 'POE::Component::Server::JSONUnix';
sub cmd_whoami { my ($self, $req, $ctx) = @_; return { user => $ENV{USER} } }
sub cmd_uptime { my ($self, $req, $ctx) = @_; return { up => time() - $^T } }
MyApp::Server->spawn(socket_path => '/tmp/app.sock');
THE CONTEXT OBJECT
Each handler receives a context object (an instance of POE::Component::Server::JSONUnix::Context) as its third argument. It carries the request and provides the reply methods, which is what makes asynchronous handlers possible: keep the object alive past the handler's return and answer when ready.
$ctx->respond_result($data)-
Send
{status => 'ok', result => $data}. $ctx->error($message, %extra)-
Send
{status => 'error', error => $message, %extra}. $ctx->respond(\%envelope)-
Send a raw response envelope.
statusdefaults tookand the requestidis added automatically. Only the first reply on a context has any effect. $ctx->request,$ctx->id,$ctx->command-
Accessors for the decoded request, its
id, and the command name. $ctx->close-
Close this client's connection once any queued output has been flushed.
$ctx->authenticated-
True if this client has completed a successful
auth_verifyexchange. $ctx->uid-
The numeric UID of the authenticated user, or
undefif not yet verified. $ctx->username-
The username corresponding to
$ctx->uid, orundefif not yet verified. $ctx->groups-
Array reference of the authenticated user's group names (primary and secondary), or an empty array reference before verification. Resolved via NSS at most once per connection and cached; see "PERMISSIONS".
$ctx->in_group($group)-
True if the authenticated user belongs to the given group, by name or by numeric GID. False before verification.
$ctx->may($command_name)-
True if this connection would currently be allowed to run the named command. Always true when no permission policy is configured. Useful for finer-grained decisions inside a handler than the per-command rules can express.
BUILT-IN COMMANDS
ping-
Returns
{pong => 1, time => <epoch>}. commands-
Returns
{commands => [ ...names... ]}-- handy for discovery. When a permission policy is configured, only the commands the caller may currently run are listed. auth_start-
Begins the Unix-ownership challenge. Returns
{cookie => "<hex>", temp_dir => "<dir>"}. The client must write the cookie string to a new regular file insidetemp_dirand then callauth_verify. auth_verify-
Completes the challenge.
argsmust containpath: the absolute path of the file the client wrote intemp_dir. The server:- 1. Confirms the path is a regular (non-symlink) file directly inside
auth_temp_dir. - 3.
stats the file to obtain the owning UID. - 4. Deletes the temp file.
On success returns
{uid => <uid>, username => "<name>"}-- plusgroups => [...]when a permission policy is configured (see "PERMISSIONS"). The connection is now considered authenticated; subsequent handlers can inspect$ctx->uid,$ctx->username, and$ctx->groups. - 1. Confirms the path is a regular (non-symlink) file directly inside
USER VERIFICATION
The ownership challenge lets the server verify which Unix user is on the other end of a connection without any password or token. Because the OS assigns file ownership based on the creating process's effective UID, a client that can write a correctly-named cookie file owned by UID N must be running as UID N.
# Server side
my $server = POE::Component::Server::JSONUnix->spawn(
socket_path => '/tmp/app.sock',
auth_required => 1, # reject other commands until authed
);
$server->register(
whoami => sub {
my ($server, $req, $ctx) = @_;
return { uid => $ctx->uid, username => $ctx->username };
},
);
# Client side (pseudo-code)
send({ command => 'auth_start' });
my $res = recv(); # {cookie => "...", temp_dir => "/tmp"}
my $path = "$res->{temp_dir}/verify_$$";
open(my $fh, '>', $path) or die $!;
print $fh $res->{cookie};
close $fh;
send({ command => 'auth_verify', args => { path => $path } });
my $auth = recv(); # {uid => 1000, username => "alice"}
send({ command => 'whoami' });
my $me = recv(); # {uid => 1000, username => "alice"}
PERMISSIONS
An optional per-command permission layer on top of "USER VERIFICATION". It is enabled by passing a permissions hash reference to "spawn"; when the argument is absent, nothing changes -- no policy is enforced, no group lookups happen, and every response looks exactly as it did before.
my $server = POE::Component::Server::JSONUnix->spawn(
socket_path => '/tmp/app.sock',
permissions => {
default => 'deny',
commands => {
status => 'allow', # anyone, even unauthenticated
reboot => { groups => ['wheel'] },
shutdown => { users => [ 'root', 0 ] }, # names or numeric ids
debug => {
users => ['zane'],
deny_users => ['nobody'],
check => sub {
my ( $server, $ctx, $command ) = @_;
return $ctx->request->{args}{dry_run};
},
},
},
},
commands => { ... },
);
Policy structure
default ('allow' or 'deny', default 'allow') applies to every command that has no entry under commands and no %DEFAULT% fallback (see below). Each entry under commands is either the string 'allow', the string 'deny', or a hash reference with any of the following keys. Entries consisting only of digits are treated as UIDs/GIDs; anything else as a name.
users
Array reference of usernames and/or numeric UIDs. Any match allows.
groups
Array reference of group names and/or numeric GIDs. Any match allows. Secondary (supplementary) group memberships count, not just the user's primary group.
deny_users, deny_groups
Same formats as users and groups; a match denies, and denies win over every allow.
check
Code reference called as $check->($server, $ctx, $command_name); a true return allows. An escape hatch for rules the lists cannot express. If it dies, the request is denied (fail closed).
%DEFAULT%
Not a rule key but a special entry name under commands: its rule (any of the forms above, string or hash) is used for every command -- known or not -- that has no entry of its own, taking precedence over the default string. This lets the fallback be a full user/group rule rather than just 'allow' or 'deny':
permissions => {
commands => {
'%DEFAULT%' => { groups => ['staff'] }, # everything not listed
status => 'allow', # except these
reboot => { groups => ['wheel'] },
},
},
When a %DEFAULT% entry is present, default still serves as the last resort for rules that contain only deny lists (see "Evaluation order").
Evaluation order
auth_start and auth_verify are always allowed -- the handshake must be reachable, or nobody could ever gain the identity the policy is written in terms of. For everything else: a hash-form rule requires authentication (even when auth_required is off globally), unauthenticated requests to such commands are refused with code => 'auth_required'. Then deny lists, then allow lists and check (any match allows). A command with no entry of its own falls back to the %DEFAULT% entry if one exists, and finally -- with no entry at all, or for entries with only deny lists -- to the default string. Refusals are ordinary error responses carrying code => 'permission_denied':
{"id":7,"status":"error","code":"permission_denied",
"error":"permission denied: user 'alice' may not run 'reboot'"}
The permission check runs before the unknown-command check, so a default-deny server does not reveal which commands exist to callers who may not run them. For the same reason the built-in commands command lists only the commands the caller may currently run when a policy is configured.
Where groups come from
Group membership is resolved with perl's getpwuid/getgrent family, which routes through the platform's NSS (or local equivalent) -- so /etc/group, LDAP, sssd, NIS, and so on all behave identically, on any Unix. The resolution collects the primary group from the passwd entry plus every secondary group that lists the user as a member.
Because those lookups can be slow on network-backed systems, they are done at most once per connection: at auth_verify time when a policy is configured (the result is also included in the auth_verify response as groups), and cached on the connection for every later check. A user's group changes therefore take effect on their next connection, which matches how Unix logins behave.
Note that this reflects the user's configured membership, not the peer process's current credential set: a process that dropped a supplementary group still counts as a member here. The policy is about who the user is, as proven by the ownership challenge, not about the process's kernel credentials.
SEE ALSO
POE, POE::Wheel::SocketFactory, POE::Wheel::ReadWrite, POE::Filter::Line, JSON::MaybeXS.
AUTHOR
Zane C. Bowers-Hadley, <vvelox at vvelox.net>
COPYRIGHT AND LICENSE
This software is Copyright (c) 2026 by Zane C. Bowers-Hadley.
This is free software, licensed under:
The GNU Lesser General Public License, Version 2.1, February 1999