Step-by-step guide to creating a Portal module. For the complete API reference, see CORE_API.md.
modules/mod_hello/mod_hello.c
#include <string.h>
#include "portal/portal.h"
/* --- Descriptor --- */
static portal_module_info_t info = {
.name = "hello",
.version = "1.0.0",
.description = "Hello world module",
.soft_deps = NULL
};
portal_module_info_t *portal_module_info(void) { return &info; }
/* --- Lifecycle --- */
int portal_module_load(portal_core_t *core)
{
core->path_register(core, "/hello", "hello");
core->path_register(core, "/hello/greet", "hello");
core->log(core, PORTAL_LOG_INFO, "hello", "Module loaded");
return PORTAL_MODULE_OK;
}
int portal_module_unload(portal_core_t *core)
{
core->path_unregister(core, "/hello");
core->path_unregister(core, "/hello/greet");
core->log(core, PORTAL_LOG_INFO, "hello", "Module unloaded");
return PORTAL_MODULE_OK;
}
/* --- Handler --- */
int portal_module_handle(portal_core_t *core, const portal_msg_t *msg,
portal_resp_t *resp)
{
(void)core;
if (strcmp(msg->path, "/hello") == 0 && msg->method == PORTAL_METHOD_GET) {
const char *text = "Hello from Portal!\n";
portal_resp_set_status(resp, PORTAL_OK);
portal_resp_set_body(resp, text, strlen(text) + 1);
return 0;
}
if (strcmp(msg->path, "/hello/greet") == 0) {
/* Read name from header */
const char *name = "world";
for (uint16_t i = 0; i < msg->header_count; i++) {
if (strcmp(msg->headers[i].key, "name") == 0)
name = msg->headers[i].value;
}
char buf[256];
snprintf(buf, sizeof(buf), "Hello, %s!\n", name);
portal_resp_set_status(resp, PORTAL_OK);
portal_resp_set_body(resp, buf, strlen(buf) + 1);
return 0;
}
portal_resp_set_status(resp, PORTAL_NOT_FOUND);
return -1;
}gcc -shared -fPIC -Wall -Wextra -Werror -std=c11 -D_GNU_SOURCE \
-Iinclude -Isrc -Ilib/libev \
-o modules/mod_hello.so \
modules/mod_hello/mod_hello.c src/core/core_message.cCreate a config file for your module in the instance's modules/ directory:
/etc/portal/<instance>/modules/mod_hello.conf
# mod_hello — Hello world module
enabled = true
[mod_hello]
greeting = Hello from Portal!enabled = true— auto-loads the module at startupenabled = false— config is loaded but module is not started- The
[mod_hello]section keys are accessible viacore->config_get(core, "hello", "greeting")
Directory structure:
/etc/portal/<instance>/modules/
├── core/ ← Infrastructure (loaded first)
│ ├── mod_cli.conf
│ ├── mod_node.conf
│ ├── mod_web.conf
│ ├── mod_ssh.conf
│ ├── mod_config_sqlite.conf
│ └── mod_config_psql.conf
└── mod_hello.conf ← Application modules
portal> module load hello
portal> ls /hello
portal> module list
Every module is a .so file that exports 4 functions:
| Symbol | Purpose |
|---|---|
portal_module_info() |
Return module name, version, description, soft deps |
portal_module_load(core) |
Initialize: register paths, open resources |
portal_module_unload(core) |
Cleanup: unregister paths, free resources |
portal_module_handle(core, msg, resp) |
Handle incoming messages |
mod_<name>.so
The <name> must match info.name. Examples: mod_cli.so, mod_db.so, mod_web.so.
Your portal_module_handle() receives every message routed to your paths. Dispatch based on msg->path and msg->method:
int portal_module_handle(portal_core_t *core, const portal_msg_t *msg,
portal_resp_t *resp)
{
if (strcmp(msg->path, "/mymod/items") == 0) {
switch (msg->method) {
case PORTAL_METHOD_GET:
/* Return list of items */
break;
case PORTAL_METHOD_SET:
/* Create/update an item (data in msg->body) */
break;
default:
portal_resp_set_status(resp, PORTAL_BAD_REQUEST);
return -1;
}
}
portal_resp_set_status(resp, PORTAL_NOT_FOUND);
return -1;
}| Source | How to read |
|---|---|
| Path | msg->path — which resource |
| Method | msg->method — what action |
| Headers | Loop msg->headers[0..header_count-1] for key-value metadata |
| Body | msg->body (raw bytes), msg->body_len (length) |
| Auth | msg->ctx->auth.user, msg->ctx->auth.labels |
portal_resp_set_status(resp, PORTAL_OK);
portal_resp_set_body(resp, data, data_len);Always set resp->status. Body is optional.
Modules communicate via core->send():
portal_msg_t *msg = portal_msg_alloc();
portal_resp_t *resp = portal_resp_alloc();
portal_msg_set_path(msg, "/db/users");
portal_msg_set_method(msg, PORTAL_METHOD_GET);
portal_msg_add_header(msg, "id", "42");
int rc = core->send(core, msg, resp);
if (rc == 0 && resp->status == PORTAL_OK) {
/* resp->body has the data */
}
portal_msg_free(msg);
portal_resp_free(resp);Important: Always free both msg and resp when done.
Declare what you'd like (not what you require):
static const char *deps[] = {"db", "cache", NULL};
static portal_module_info_t info = {
.name = "api",
.version = "1.0.0",
.description = "REST API module",
.soft_deps = deps
};Check before using:
if (core->module_loaded(core, "db")) {
core->send(core, db_msg, db_resp);
} else {
portal_resp_set_status(resp, PORTAL_UNAVAILABLE);
portal_resp_set_body(resp, "Database unavailable\n", 21);
}Operator-facing reference:
docs/SECURITY.mdcovers the system-level model. This section is the module-author idiom only.
Restrict paths with labels in load():
/* Public — anyone */
core->path_register(core, "/api/public", "api");
/* Admin only */
core->path_register(core, "/api/admin", "api");
core->path_add_label(core, "/api/admin", "admin");
/* Admin or dev */
core->path_register(core, "/api/debug", "api");
core->path_add_label(core, "/api/debug", "admin");
core->path_add_label(core, "/api/debug", "dev");The core enforces this automatically. Your handler only receives messages that passed the ACL check.
The labels above gate whether a caller can call the path. A different question is which of the rows your handler returns should actually be visible to this caller. That's Law 15.
When your handler iterates and emits rows, call core->labels_allow per row with the row's own label set. Skip the row on a zero return. That's all.
/* Worked example — what mod_node does for /node/resources/peers.
* See modules/mod_node/mod_node.c at the /node/resources/peers handler. */
for (int i = 0; i < g_peer_count; i++) {
node_peer_t *p = g_peers[i];
portal_labels_t row_labels;
peer_get_labels(p, &row_labels); /* your module fills this */
if (!core->labels_allow(core, msg->ctx, &row_labels))
continue;
/* … emit the row into the response buffer as you would anyway … */
}You do not register a callback with the core. You do not implement a new struct. You call the predicate inline, in the loop you were writing already. That's the whole API.
For detail lookups (single row requested by name), return the same "not found" response the handler uses when the row truly doesn't exist — do not distinguish "hidden" from "absent" to the caller.
One escape hatch exists for supervisors: a caller carrying the label sys.see_all bypasses the filter. Each bypass emits /events/acl/bypass so the audit trail stays complete. Module authors do not need to implement this — it's handled by the core wrapper that backs labels_allow.
The default is permissive: a row with no labels is public. Adding labels to rows is how a module opts into scoping. See docs/PHILOSOPHY.md §Law 15 for the principle and docs/CORE_API.md §Group-Scoped Output for the full contract.
When federation_strict_identity = true is set on mod_node, federation peers exchange identity at handshake time and every inbound message is dispatched as the resolved local user, exactly as if the call came from a local CLI or HTTP session. Your handler reads msg->ctx->auth.user and msg->ctx->auth.labels the same way for both — it cannot tell, and shouldn't care, that the message came in over federation.
Two things change for module authors:
-
msg->ctx->source_nodeis now populated on federation-sourced messages — the name of the peer that sent the message. Always was declared, never was set; mod_node fills it in on every inbound dispatch. Use it when your handler needs to attribute an action to a specific peer (e.g. for audit logging or per-peer state). For local CLI/HTTP messages it stays NULL. -
Anonymous calls from federation are real now. When strict mode is on and a peer has no resolved identity (key didn't validate or no exchange ran),
msg->ctx->auth.useris NULL. Don't assume there's always a username. The standard ACL gate already handles this — labeled paths deny anonymous, unlabeled paths still allow it.
If you're writing a new module that needs to do its own key lookup (e.g. some other authenticated message exchange), use core->auth_find_by_key(core, key, out_user, sizeof(out_user), &out_labels) — returns 1 on match without creating a session. See docs/CORE_API.md §Federation identity exchange.
For sockets, serial ports, files, or any fd-based I/O:
static void on_data(int fd, uint32_t events, void *userdata)
{
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) {
core->fd_del(core, fd);
close(fd);
return;
}
/* process buf[0..n-1] */
}
int portal_module_load(portal_core_t *core)
{
int fd = open("/dev/ttyS0", O_RDONLY);
core->fd_add(core, fd, EV_READ, on_data, NULL);
/* ... */
}Events: EV_READ, EV_WRITE (from embedded libev, cross-platform).
Your module can use built-in core services:
/* List all paths */
portal_msg_set_path(msg, "/core/paths");
portal_msg_set_method(msg, PORTAL_METHOD_GET);
/* List paths under a prefix */
portal_msg_set_path(msg, "/core/ls");
portal_msg_add_header(msg, "prefix", "/mymod");
/* Check status */
portal_msg_set_path(msg, "/core/status");
/* Resolve a relative path */
portal_msg_set_path(msg, "/core/resolve");
portal_msg_add_header(msg, "cwd", "/mymod");
portal_msg_add_header(msg, "target", "../other");See CORE_API.md Section 7 for the full list.
Before releasing a module:
Basic:
- Exports exactly 4 symbols
-
info.namematches the.sofilename (mod_<name>.so) - All paths registered in
load(), unregistered inunload() - All fds registered in
load(), removed inunload() - All memory allocated in
load(), freed inunload() - Uses
core->log()for all output - Uses
core->fd_add()for all I/O - Checks
module_loaded()before using soft deps - Sets
resp->statusin every handler path - Compiles with
-Wall -Wextra -Werror
Law 8 — Resource Properties:
- Every
path_register()followed bypath_set_access() - Resources use
PORTAL_ACCESS_READ - Functions use
PORTAL_ACCESS_RW - Labels set on restricted paths
Law 9 — Module Authentication:
- Module can read
[mod_name] user=andkey=from config - Default: runs as root if no credentials
Law 10 — Everything Is an Event:
-
event_emit()called on every state change (set, del, create, send, etc.) - Events registered with
event_register()inload() - CLI commands available for all module resources and functions
- CLI commands registered via
portal_cli_register()inload(), unregistered inunload()
Documentation:
- Header comment on source file describing purpose
- All paths follow convention:
/<module>/resources/and/<module>/functions/
Interactive terminal access to any peer via a dedicated TLS connection the target opens back to the initiator. Real PTY, real PAM auth, zero federation worker burn.
portal:/> shell # Local — forkpty direct, no network
portal:/> shell <peer_name> # Remote — dial-back TLS channel
Connected to <peer_name> (Ctrl-] to disconnect)
<peer> login: monitor
Password: ← /bin/su + PAM against /etc/shadow
[monitor@peer ~]$
Federation carries only a one-shot signal (/shell/functions/dialback_request
with a random 32-byte session_id). The target spawns a pthread that
opens a fresh TCP+TLS connection to the initiator's shell_port
(default 2223), announces the session_id, forks a PTY, drops
privileges to nobody, and runs /bin/su -l <user>. All shell data
flows over that private TLS connection — never through the federation
worker pool.
- Local (
shell):mod_cliforks a PTY directly, relay thread (PTY ↔ client fd). - Remote (
shell <peer>):mod_shell.open_remotegenerates a session, signals the peer through federation, waits for the dial-back, hands the resulting bridge fd to the CLI. The target'sdialback_threaddoes TCP + TLS + PAM auth via/bin/su+ PTY relay on its own pthread.
See modules/mod_shell/README.md for the full protocol, security model, thread breakdown, and operational notes.
util-linux 2.40+ rejects forkpty()-allocated PTYs with FATAL: bad tty. /bin/su uses the same PAM stack (account, auth, session) without that restriction, so it works cleanly on any kernel PTY. Operators who have a getty-style wrapper and want /bin/login back can override shell_login_binary in config.
Portal runs as root. /bin/su is SUID root; when invoked from root, PAM's pam_rootok lets it skip password auth entirely. The target-side PTY child must setuid(nobody) before exec so that PAM goes all the way through the auth stack. If the privilege drop fails, the login aborts — exec'ing su as root would be an unauthenticated root shell.
enabled = true
[mod_shell]
# Legacy message-based API (scripts/automation)
timeout = 10 # Max seconds per stateless command
shell = /bin/sh # Shell for /shell/functions/{exec,open}
allow_exec = true
max_output = 65536
session_ttl = 3600
# Dial-back channel (CLI shell <peer>)
shell_port = 2223 # TLS listener port (0 = disabled)
shell_bind = 0.0.0.0
shell_tls_cert = # Default: instance federation cert
shell_tls_key = # Default: instance federation key
shell_advertise_host = # Host the target dials back to
shell_login_binary = /bin/su # What runs after privilege drop
shell_dial_timeout = 10 # Seconds to wait for dial-back
access_label = root # Group required on all shell pathsThe initiator must have shell_port open in its firewall. The target does not need any inbound rules — it only opens outbound connections. NAT'd / private-only devices work without port forwarding.
Every registered path can have a description:
core->path_register(core, "/mymod/functions/action", "mymod");
core->path_set_access(core, "/mymod/functions/action", PORTAL_ACCESS_RW);
core->path_set_description(core, "/mymod/functions/action",
"Does something useful. Header: param_name (required)");Users discover it with:
portal:/> help /mymod/functions/action
portal:/> help mymod
portal:/> help get