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.

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->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 inside temp_dir and then call auth_verify.

auth_verify

Completes the challenge. args must contain path: the absolute path of the file the client wrote in temp_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>"} -- 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"}

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