Home / TLS / Module Guide

Module Guide

Step-by-step guide to creating a TLS module. For the complete API reference, see Core API.

Quick Start

1. Create the source file:

modules/mod_hello/mod_hello.c

2. Write the module:

#include "portal/portal.h"
#include <string.h>

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

int portal_module_load(portal_core_t *core) {
    core->path_register(core, "/hello/resources/greeting", "hello");
    core->path_set_access(core, "/hello/resources/greeting", PORTAL_ACCESS_READ);
    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/resources/greeting");
    return PORTAL_MODULE_OK;
}

int portal_module_handle(portal_core_t *core, const portal_msg_t *msg,
                          portal_resp_t *resp) {
    (void)core;
    if (strcmp(msg->path, "/hello/resources/greeting") == 0) {
        portal_resp_set_status(resp, PORTAL_OK);
        portal_resp_set_body(resp, "Hello from TLS!\n", 16);
        return 0;
    }
    portal_resp_set_status(resp, PORTAL_NOT_FOUND);
    return -1;
}

3. Build:

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.c

4. Configure — create /etc/portal/<instance>/modules/mod_hello.conf:

# mod_hello — Hello world module
enabled = true

[mod_hello]
greeting = Hello from TLS!

5. Load at runtime:

portal:/> module load hello
portal:/> get /hello/resources/greeting
Hello from TLS!
That's it. Five steps from empty file to running module. The rest of this guide covers the patterns you will use in every real module.

Module Anatomy

Every module is a shared library (.so) that exports exactly 4 symbols. No more, no less. The core calls them in order: info → load → handle (N times) → unload.

SymbolPurpose
portal_module_info()Return name, version, description, soft deps
portal_module_load(core)Initialize: register paths, open resources
portal_module_unload(core)Cleanup: unregister paths, free memory
portal_module_handle(core, msg, resp)Handle incoming messages

File naming: mod_<name>.so — the <name> part must match info.name. If info.name is "hello", the file must be mod_hello.so.

Handling Messages

The handler receives every message routed to your paths. A typical pattern dispatches on path first, then on 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 */ break;
        case PORTAL_METHOD_SET:  /* create/update */ break;
        default:
            portal_resp_set_status(resp, PORTAL_BAD_REQUEST);
            return -1;
        }
    }
    portal_resp_set_status(resp, PORTAL_NOT_FOUND);
    return -1;
}

Reading input: everything you need is in the portal_msg_t structure:

SourceHow to read
Pathmsg->path
Methodmsg->method
Headersmsg->headers[0..header_count-1]
Bodymsg->body, msg->body_len
Authmsg->ctx->auth.user, msg->ctx->auth.labels

Talking to Other Modules

Modules communicate by sending messages through the core. You never call another module's functions directly — you send a message to its path and the core routes it:

/* Ask the cache module for a value */
portal_msg_t *req = portal_msg_alloc();
portal_msg_set_path(req, "/cache/functions/get");
portal_msg_set_method(req, PORTAL_METHOD_GET);
portal_msg_add_header(req, "key", "session_token");

portal_resp_t resp;
int rc = core->send(core, req, &resp);

if (rc == 0 && resp.status == PORTAL_OK) {
    /* resp.body contains the cached value */
    core->log(core, PORTAL_LOG_DEBUG, "mymod",
              "Got value: %.*s", (int)resp.body_len, resp.body);
}

portal_msg_free(req);

This is the same mechanism whether the target module is local or on a remote node. The core handles routing transparently (Law 7).

Soft Dependencies

If your module benefits from another module but can work without it, declare it as a soft dependency in info and check at runtime:

static const char *deps[] = { "cache", NULL };

static portal_module_info_t info = {
    .name = "mymod", .version = "1.0.0",
    .description = "My module with optional caching",
    .soft_deps = deps
};

int portal_module_handle(portal_core_t *core, const portal_msg_t *msg,
                          portal_resp_t *resp) {
    /* Check if cache is available before using it */
    if (core->module_loaded(core, "cache")) {
        /* Use cache — send message to /cache/... */
    } else {
        /* Proceed without cache — degrade gracefully */
    }
    /* ... */
}
Law 4: No Hard Dependencies. If module A needs module B and B is not loaded, A does not crash. Every dependency is soft. Every absence is graceful.

Access Control

Every path you register must declare its access mode (Law 8). You can also restrict paths to specific labels. The core enforces ACL automatically — your handler only runs if the caller has permission:

int portal_module_load(portal_core_t *core) {
    /* Register a read-only public path */
    core->path_register(core, "/mymod/resources/status", "mymod");
    core->path_set_access(core, "/mymod/resources/status", PORTAL_ACCESS_READ);

    /* Register a read-write path restricted to admins */
    core->path_register(core, "/mymod/resources/config", "mymod");
    core->path_set_access(core, "/mymod/resources/config", PORTAL_ACCESS_RW);
    core->path_add_label(core, "/mymod/resources/config", "admin");

    /* Register a write-only path for operators and admins */
    core->path_register(core, "/mymod/functions/reset", "mymod");
    core->path_set_access(core, "/mymod/functions/reset", PORTAL_ACCESS_WRITE);
    core->path_add_label(core, "/mymod/functions/reset", "admin");
    core->path_add_label(core, "/mymod/functions/reset", "operator");

    return PORTAL_MODULE_OK;
}

A path with no labels is open to all authenticated users. A path with labels requires the caller to have at least one matching label. The root user bypasses all ACL checks.

Async I/O

If your module reads from a serial port, socket, or any file descriptor, you must use core->fd_add() to register it with the event loop. Never block the event loop (Law 13):

static void serial_read_cb(portal_core_t *core, int fd, void *userdata) {
    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        core->log(core, PORTAL_LOG_DEBUG, "serial",
                  "Read %zd bytes from serial port", n);
        /* Process data, emit event */
        core->event_emit(core, "/events/serial/data", buf, n);
    }
}

int portal_module_load(portal_core_t *core) {
    int fd = open("/dev/ttyUSB0", O_RDONLY | O_NONBLOCK);
    if (fd < 0) {
        core->log(core, PORTAL_LOG_WARN, "serial", "Cannot open serial port");
        return PORTAL_MODULE_OK;  /* degrade gracefully */
    }
    core->fd_add(core, fd, serial_read_cb, NULL);
    core->log(core, PORTAL_LOG_INFO, "serial", "Listening on /dev/ttyUSB0");
    return PORTAL_MODULE_OK;
}

int portal_module_unload(portal_core_t *core) {
    core->fd_remove(core, fd);
    close(fd);
    return PORTAL_MODULE_OK;
}

The callback fires whenever data is available on the file descriptor. The event loop remains free to process other messages. For CPU-intensive work, use the thread pool instead.

Release Checklist

Before shipping a module, verify every item:

  • Exports exactly 4 symbols
  • info.name matches .so filename
  • All paths registered in load(), unregistered in unload()
  • All fds registered in load(), removed in unload()
  • All memory freed in unload()
  • Uses core->log() for all output
  • Uses core->fd_add() for all I/O
  • Checks module_loaded() before using soft deps
  • Sets resp->status in every handler path
  • Compiles with -Wall -Wextra -Werror
  • Every path_register followed by path_set_access (Law 8)
  • event_emit() on every state change (Law 10)
If unload leaks, don't ship. A module that cannot cleanly unload is a module that cannot be hot-reloaded. If you can't hot-reload, you violate Law 5. Run module load / module unload in a loop under valgrind until it's clean.