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. cmd is 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.

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.

auth_method

How clients prove their identity. One of:

auto (default)

Use kernel peer credentials when the platform supports them (Linux, FreeBSD, macOS, and other BSDs -- see "KERNEL PEER-CREDENTIAL AUTHENTICATION"), falling back to the cookie-file challenge otherwise. auth_start advertises the kernel path with peercred => 1 while still returning a cookie/temp_dir, so a client may use either.

peercred

Offer only kernel peer credentials. No cookie is issued; auth_verify requires no file. On a platform without support, authentication cannot complete.

Offer only the cookie-file challenge, exactly as earlier versions did, even where kernel peer credentials are available.

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

die with 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 $ctx somewhere, 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. status defaults to ok and the request id is 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_verify exchange.

$ctx->uid

The numeric UID of the authenticated user, or undef if not yet verified.

$ctx->username

The username corresponding to $ctx->uid, or undef if not yet verified.

$ctx->peer_uid, $ctx->peer_gid

The peer process's kernel-verified UID and GID, as recorded by the OS when the connection was accepted. Available from the first request -- independent of any auth_start/auth_verify handshake -- on platforms that support it (see "KERNEL PEER-CREDENTIAL AUTHENTICATION"), and undef elsewhere. This is the identity the peercred auth path is built on.

$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 authentication. Returns {cookie => "<hex>", temp_dir => "<dir>"} for the Unix-ownership challenge: the client writes the cookie string to a new regular file inside temp_dir and then calls auth_verify.

When the server offers kernel peer-credential authentication (the default on supported platforms; see "KERNEL PEER-CREDENTIAL AUTHENTICATION") the response also carries peercred => 1, signalling that the client may skip the file and call auth_verify with no arguments. Under auth_method => 'peercred' only {peercred => 1} is returned, with no cookie.

auth_verify

Completes authentication.

Called with no args.path it authenticates from the kernel peer credentials recorded at connect -- no file, no cookie -- and returns the same identity fields described below. This path is available only when the server offers it.

Called with args.path (the absolute path of the file the client wrote in temp_dir) it completes the ownership challenge. 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>"} -- plus groups => [...] 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.

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"}

KERNEL PEER-CREDENTIAL AUTHENTICATION

On platforms that expose it, the kernel already knows the UID and GID of the process on the other end of a Unix domain socket -- it recorded them at connect(2) time. The server reads them directly from the socket, so a client can be authenticated with no cookie, no temp file, and no filesystem access at all. The credential is supplied by the kernel and cannot be forged.

This is the default (auth_method => 'auto') where supported and falls back to the "USER VERIFICATION" cookie challenge elsewhere, so no configuration is required. The wire protocol is unchanged: auth_start simply adds peercred => 1 to its reply, and the client completes the handshake by calling auth_verify with no path:

# Client side (pseudo-code) -- note: no file is ever created
send({ command => 'auth_start' });
my $res = recv();                  # {peercred => 1, cookie => ..., temp_dir => ...}

if ($res->{peercred}) {
    send({ command => 'auth_verify' });         # no args, no file
} else {
    # ... write the cookie file and verify by path as above ...
}
my $auth = recv();                 # {uid => 1000, username => "alice"}

The bundled POE::Component::Server::JSONUnix::Client and POE::Component::Server::JSONUnix::BlockingClient do this automatically: their authenticate uses the kernel path whenever the server advertises it and creates no file.

Supported platforms

  • Linux -- SO_PEERCRED (struct ucred).

  • FreeBSD, DragonFly, macOS/Darwin -- LOCAL_PEERCRED (struct xucred).

  • NetBSD -- LOCAL_PEERCRED (struct unpcbid).

  • OpenBSD -- SO_PEERCRED (struct sockpeercred).

Everywhere else the peer credentials are simply reported as undef and the cookie challenge is used. All reads are done with Perl's built-in getsockopt; no XS or non-core modules are involved.

Both paths end in the same place: the connection is marked authenticated and $ctx->uid/$ctx->username (and, under a policy, $ctx->groups) are populated identically. The difference is only in how the UID is obtained -- from the kernel, or from the ownership of a file the client created. The kernel path additionally exposes $ctx->peer_uid and $ctx->peer_gid before any handshake at all.

Note one subtlety: kernel credentials reflect the connecting process's effective UID/GID, whereas the ownership challenge reflects whoever owns the cookie file. These normally coincide. As with the cookie path, group membership used by "PERMISSIONS" is still resolved from the platform's user database by UID, not taken from the process's live supplementary-group set.

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