Fugu

 view release on metacpan or  search on metacpan

lib/Fugu/Control.pm  view on Meta::CPAN

# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

package Fugu::Control;
our $VERSION = '0.5.1';

use v5.34;
use warnings;
use experimental 'signatures';
no feature qw(indirect multidimensional bareword_filehandles);

use IO::Socket::UNIX;
use JSON::PP ();
use Socket   qw(SOCK_STREAM SOL_SOCKET);

use Fugu::Imsg;
use Fugu::Log;

# Fugu::Control - a control socket for a running daemon.
#
# A tool that reports on a daemon has two ways to get its answer. It
# can guess from files on disk, or it can ask the daemon. The first
# way reads what the daemon wrote at some earlier time, and it is
# wrong whenever the two disagree. This module is the second way.
#
# The server registers with a Fugu::EventLoop and answers commands
# from process state. The client connects, sends one request, and
# reads the reply. The payload is JSON, and the transport is the
# imsg(3) framing of Fugu::Imsg.
#
# The socket carries no secret. That is a rule for the caller, which
# writes the command handlers, and this module cannot enforce it. A
# daemon whose configuration holds a setup code and a broker password
# must not answer a command that echoes them back.
#
# Everything that arrives on the socket is untrusted. An unknown
# command, a payload that is not JSON, and a frame over the limit all
# give an error reply. None of them ends the daemon.

# The message types. A reply that does not fit one imsg frame is sent
# as several TYPE_REPLY_PART frames and one TYPE_REPLY, so a reader
# knows when it holds the whole answer without counting.
use constant {
	TYPE_REQUEST    => 1,
	TYPE_REPLY      => 2,
	TYPE_REPLY_PART => 3,
	TYPE_ERROR      => 4,
};

# The bound on one whole reply, across every frame. A control reply
# is a status line or a device list, and a megabyte is far above
# either. The bound exists so a client cannot be made to hold an
# unbounded string by a server that never stops sending.
use constant MAX_REPLY => 1048576;

# How long a client waits for one frame.
use constant DEFAULT_TIMEOUT => 5;

# The platform predicate of peer. Only OpenBSD reports the peer
# credentials in the struct sockpeercred order: the user id, then the
# group id, then the process id. The Linux struct ucred holds the
# process id first, and one module must not carry two field orders.
# The base perl of OpenBSD exports Socket::SO_PEERCRED (0x1022), so
# no compiled module is necessary.
use constant PEER_SUPPORTED => $^O eq 'openbsd';

my $JSON = JSON::PP->new->utf8->canonical;

# Fugu::Control->new(%args):
#	path => $socket		the UNIX socket to serve (required)
#	log  => $logger		default Fugu::Log->default
#
#	Make a server. The method opens nothing. Call listen.
sub new ( $class, %args )
{
	my $path = $args{path};
	die 'path parameter required'
	    unless defined $path && length $path;

	return bless {
		path     => $path,
		log      => $args{log},
		commands => {},
		listener => undef,
		clients  => {},
		peers    => {},
		error    => undef,
	}, $class;
}

# $self->register($command, $code):
#	Add a command. The code gets the decoded arguments hashref and
#	returns the reply, which must encode as JSON.
#
#	A handler that dies gives the caller an error reply. A daemon
#	must not fall over because one handler had a bad day.
sub register ( $self, $command, $code )
{
	die 'command handler must be a code reference'
	    unless ref $code eq 'CODE';

	$self->{commands}{$command} = $code;

	return $self;
}

# $self->path: the socket path.
sub path ($self)
{
	return $self->{path};
}

# $self->error: the most recent failure.
sub error ($self)
{
	return $self->{error};
}

# $self->listen:
#	Bind the socket and start accepting. The method returns the

lib/Fugu/Control.pm  view on Meta::CPAN

	if ( defined $gid ) {
		unless ( chown -1, $gid, $self->{path} ) {
			return $self->_unbind( $listener,
				"Cannot chown $self->{path} to group $gid: $!"
			);
		}
		unless ( chmod $mode, $self->{path} ) {
			return $self->_unbind( $listener,
				"Cannot chmod $self->{path}: $!" );
		}
	}

	$self->{listener} = $listener;
	$self->_log->debug( 'Control socket listening on %s', $self->{path} );

	$loop->add_fd( $listener,
		read => sub ($) { $self->accept_one($loop) } );

	return $self;
}

# $self->_unbind($listener, $reason):
#	Take a half-built socket down. A half-built socket must never
#	accept a connection, so the listener closes and the path goes.
sub _unbind ( $self, $listener, $reason )
{
	$self->{error} = $reason;

	unless ( CORE::close $listener ) {
		$self->_log->error( 'Cannot close the listener on %s: %s',
			$self->{path}, $! );
	}
	unless ( unlink $self->{path} ) {

		# A socket that stays behind reads as a stale socket at
		# the next start, and _remove_stale takes it then.
		$self->_log->error( 'Cannot remove %s: %s', $self->{path}, $! );
	}

	return;
}

# $self->accept_one($loop):
#	Take one connection and register it as a read handler on the
#	loop. The peer of an open connection cannot change, so one
#	getsockopt(2) per connection is enough, and this is the place
#	for it.
sub accept_one ( $self, $loop )
{
	my $client = $self->{listener}->accept or return;

	my ( $peer, $fault );
	if (PEER_SUPPORTED) {
		( $peer, $fault ) = _read_peer($client);

		# The read cannot fail on a healthy UNIX socket, so a
		# failure names a broken assumption. A control socket
		# that cannot name its peer must not answer.
		unless ($peer) {
			$self->_log->error(
				'Cannot read the peer credentials on %s: %s',
				$self->{path}, $fault );
			CORE::close $client;
			return;
		}
	}

	my $imsg = Fugu::Imsg->new( fh => $client );
	$self->{clients}{ fileno $client } = $imsg;
	$self->{peers}{ fileno $client }   = $peer;

	# The loop already knows the socket is readable, thus the read
	# takes what arrived and returns. A request that spans two
	# reads waits in the buffer for the next readable event.
	$loop->add_fd(
		$client,
		read => sub ($fh) {
			$self->_serve_one($imsg)
			    or $self->_drop_client( $fh, $loop );
		} );

	return;
}

# $self->shutdown:
#	Close every connection, close the listener, and remove the
#	socket. A socket left behind names a daemon that is not there.
sub shutdown ( $self, %args )
{
	my $loop = $args{loop};

	for my $key ( keys %{ $self->{clients} } ) {
		my $imsg = delete $self->{clients}{$key};
		delete $self->{peers}{$key};
		$loop->remove_fd( $imsg->{fh} ) if $loop && $imsg->{fh};
		$imsg->close;
	}

	if ( $self->{listener} ) {
		$loop->remove_fd( $self->{listener} ) if $loop;
		CORE::close $self->{listener};
		$self->{listener} = undef;
	}

	unlink $self->{path};

	return $self;
}

# $self->_serve_one($imsg):
#	Read one request and answer it. The method returns 1 when the
#	connection can carry another, and 0 when it is finished. The
#	read never blocks: the loop calls again when more arrives.
sub _serve_one ( $self, $imsg )
{
	my $message = $imsg->recv( timeout => 0 );
	unless ($message) {
		return 0 if $imsg->is_dead;

		# No whole frame arrived yet. The loop calls again.
		return 1;
	}

	my $peerid = $message->{peerid};

	if ( $message->{type} != TYPE_REQUEST ) {
		return $self->_send_error( $imsg, $peerid,
			'not a control request' );
	}

	my $request = eval { $JSON->decode( $message->{data} ) };
	unless ( ref $request eq 'HASH' ) {
		return $self->_send_error( $imsg, $peerid,
			'request is not a JSON object' );
	}

	my $command = $request->{command};
	unless ( defined $command && length $command ) {
		return $self->_send_error( $imsg, $peerid,
			'request names no command' );
	}

	my $handler = $self->{commands}{$command};
	unless ($handler) {
		return $self->_send_error( $imsg, $peerid,
			"unknown command: $command" );
	}

	# The credentials belong to the connection. peer answers only
	# while the handler runs, so the local scope ends with the
	# call.
	my $reply = do {
		local $self->{current_peer} =
		    $self->{peers}{ fileno $imsg->{fh} };
		eval { $handler->( $request->{args} // {} ) };
	};
	if ($@) {
		my $reason = $@;
		chomp $reason;
		$self->_log->error( 'Control command %s died: %s',
			$command, $reason );
		return $self->_send_error( $imsg, $peerid,
			"command failed: $command" );
	}

	return $self->_send_reply( $imsg, $peerid, $reply );
}

# $self->_send_reply($imsg, $peerid, $reply):
#	Send one reply, in as many frames as it needs. Every frame but
#	the last is a part.
sub _send_reply ( $self, $imsg, $peerid, $reply )
{
	my $body =
	    eval { $JSON->encode( { ok => JSON::PP::true, reply => $reply } ) };
	unless ( defined $body ) {
		return $self->_send_error( $imsg, $peerid,
			'reply does not encode as JSON' );
	}

	if ( length($body) > MAX_REPLY ) {
		return $self->_send_error( $imsg, $peerid, 'reply too large' );
	}

	my $chunk = Fugu::Imsg::MAX_PAYLOAD;
	while ( length($body) > $chunk ) {
		$imsg->send(
			type   => TYPE_REPLY_PART,
			peerid => $peerid,
			data   => substr( $body, 0, $chunk, '' ),
		) or return 0;
	}

	return $imsg->send(
		type   => TYPE_REPLY,
		peerid => $peerid,
		data   => $body,
	) ? 1 : 0;
}

# $self->_send_error($imsg, $peerid, $reason):
#	Send an error reply. The method returns 1: a bad request is
#	the peer's problem, and the connection stays usable.
sub _send_error ( $self, $imsg, $peerid, $reason )
{
	$self->_log->debug( 'Control error: %s', $reason );

	$imsg->send(
		type   => TYPE_ERROR,
		peerid => $peerid,
		data   => $JSON->encode(
			{ ok => JSON::PP::false, error => $reason }
		),
	) or return 0;

	return 1;
}

# Fugu::Control->peer_supported:
#	Report if the platform gives peer credentials in the struct
#	sockpeercred order. A caller or a test uses this, not a log
#	line, to tell "not supported" from "the read failed".
sub peer_supported ($)
{
	return PEER_SUPPORTED ? 1 : 0;
}

# $self->peer:
#	The credentials of the connection that the server answers now,
#	as a hash reference with uid, gid and pid. The method returns
#	undef outside a handler call, and undef where peer_supported is
#	false. It holds no policy: the group of the socket is the
#	coarse gate, and the handler is the fine gate.
sub peer ($self)
{
	return $self->{current_peer};
}

# _read_peer($client):
#	The peer credentials of one connection, as ($peer, $fault).
#	One of the two is defined. getsockopt(2) with SO_PEERCRED
#	returns a struct sockpeercred, and the length check guards the
#	unpack.
sub _read_peer ($client)
{
	# The constant probe fails closed. The base perl of OpenBSD
	# 7.8 exports Socket::SO_PEERCRED, and a real guest verified
	# the read. Socket still croaks at call time for a constant
	# that a platform does not define, and the server must log and
	# close instead.
	my $option = eval { Socket::SO_PEERCRED() };
	return ( undef, 'Socket defines no SO_PEERCRED' )
	    unless defined $option;

	my $cred = getsockopt( $client, SOL_SOCKET, $option );
	return ( undef, "getsockopt: $!" ) unless defined $cred;

	# A size other than 12 names an other struct layout, so the
	# read fails closed instead of unpacking a guess.
	return ( undef, length($cred) . ' credential bytes, not 12' )
	    unless length($cred) == 12;

	my ( $uid, $gid, $pid ) = _unpack_peer($cred);

	return ( { uid => $uid, gid => $gid, pid => $pid }, undef );
}

# _unpack_peer($cred):
#	The three fields of a struct sockpeercred: the user id, the
#	group id, and the process id. The id fields are unsigned, so a
#	uid at or above 2**31 stays positive. The process id is
#	signed, per pid_t.
sub _unpack_peer ($cred)
{
	return unpack 'L2l', $cred;
}

# $self->_drop_client($fh, $loop):
#	Forget a connection that ended.
sub _drop_client ( $self, $fh, $loop )
{
	# The handle is still open here: recv marks {dead} without
	# closing, so fileno answers and the client entry is there.
	my $imsg = delete $self->{clients}{ fileno $fh };
	delete $self->{peers}{ fileno $fh };

	$loop->remove_fd($fh);
	$imsg->close;

	return;
}

# $self->_remove_stale:
#	Remove a socket that no daemon is behind. A socket that
#	something still answers on is another daemon, and this one
#	must not take its name.
sub _remove_stale ($self)
{
	return 1 unless -e $self->{path};



( run in 1.440 second using v1.01-cache-2.11-cpan-007c89162af )