From 3e231aed672204b16368b0afac32d367f5cc307e Mon Sep 17 00:00:00 2001 From: Mari Date: Thu, 14 May 2026 00:13:10 -0400 Subject: [PATCH] Enable systemd socket activation for Grafito. Systemd socket activation allows the systemd daemon to control the server socket for a service, starting up the service upon receiving a connection on that socket. The socket is passed to the service, which accepts the new connection and any others that might arrive later. This has multiple benefits, as described in the blog post here: https://0pointer.de/blog/projects/socket-activation.html Relevant to Grafito are: * the ability to shut down the service to update it without losing any requests * the ability to tightly sandbox the service so that it is unable to make any network requests without losing its core ability to receive and respond to requests made of it * the ability to conserve resources by only starting the service when it is needed Grafito only really needs to be running when you want to look at your logs. And socket activation is half of that puzzle - it starts up Grafito when you want to use it. The other half is shutting Grafito down when it is no longer being used. This change adds a new flag, --idle-timeout-sec, which causes Grafito to shut down after it has spent the prescribed time without receiving any new requests. When socket activation is used, Grafito does not shut down the socket when it shuts down, so any new requests which come in will be received by systemd, which will start it right back up again. --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++ src/grafito.cr | 8 +++++ src/grafito_helpers.cr | 34 ++++++++++++++++++- src/main.cr | 42 +++++++++++++++++++---- 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0a13aa6..6c2ee95 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,81 @@ To run Grafito as a systemd service, you can create a service file. sudo systemctl status grafito.service ``` +## Running with systemd socket activation + +To conserve resources and enable tighter sandboxing, it's also possible to run +grafito as a [socket-activated](https://0pointer.de/blog/projects/socket-activation.html) +systemd service. This makes grafito start up on demand. You can also use the +`--idle-timeout-sec` flag to make grafito shut down after a set period without +any new requests. + +1. **Create the service file:** + + Create a file named `grafito.service` in `/etc/systemd/system/` (or `~/.config/systemd/user/` for a user service) with the following content. Adjust paths and user/group as necessary. + + ```ini + [Unit] + Description=Grafito Log Viewer + After=network.target + + [Service] + Type=simple + DynamicUser=yes + # If set to "systemd-journal" it can access all logs in the system + # Change if that is not what you want. + Group=systemd-journal + + # --- Authentication Configuration --- + # Set these environment variables to enable Basic Authentication. + # If GRAFITO_AUTH_USER and GRAFITO_AUTH_PASS are not set, Grafito will run without authentication. + Environment="GRAFITO_AUTH_USER=your_grafito_username" + Environment="GRAFITO_AUTH_PASS=your_strong_grafito_password" + + # Replace with the actual path to your Grafito directory + WorkingDirectory=/usr/local/bin/ + # Shut down after 5 minutes without requests + ExecStart=/usr/local/bin/grafito --idle-timeout-sec=300 + ``` + +2. **Create the socket file:** + + Create a [systemd socket file](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html) named `grafito.socket` in `/etc/systemd/system/` (or `~/.config/systemd/user/` for a user service) with the following content. This file should have the same basename as the service file created above - in this case, `grafito`. + + ```ini + [Unit] + Description=Socket for grafito log UI + + [Socket] + # Set the port and, if desired, address to listen on here + ListenStream=1111 + NoDelay=true + + [Install] + WantedBy=sockets.target + ``` + +3. **Reload systemd daemon:** + + ```bash + sudo systemctl daemon-reload + ``` + +4. **Enable and start the socket:** + + ```bash + sudo systemctl enable --now grafito.socket + ``` + +5. **Make a request to wake the server:** + ```bash + curl http://localhost:1111 + ``` + +6. **Check the status:** + ```bash + sudo systemctl status grafito.service grafito.socket + ``` + ## Journald Permissions By default, `journalctl` (and therefore Grafito) can only access the logs of the user running the command. To allow Grafito to access all system logs, the user running the Grafito process needs to be part of a group that has permissions to read system-wide journal logs. diff --git a/src/grafito.cr b/src/grafito.cr index 34097df..8a8e421 100644 --- a/src/grafito.cr +++ b/src/grafito.cr @@ -31,6 +31,9 @@ module Grafito # Global unit restriction - when set, only logs from these units will be shown class_property allowed_units : Array(String)? = nil + # Idle timeout - when set, will shut down if idle for the set number of seconds + class_property idle_timeout_sec : Int32 = 0 + # AI provider instance - nil if no provider is configured class_property ai_provider : AI::Provider? = nil @@ -60,6 +63,11 @@ module Grafito # Register all Kemal routes (called after base_path is set) # ameba:disable Metrics/CyclomaticComplexity def self.register_routes + if idle_timeout_sec > 0 + add_handler IdleShutdownHandler.new(timeout_sec: idle_timeout_sec, logger: Log) + end + + # ## The `/logs` endpoint # # Exposes the Journalctl wrapper via a REST API. diff --git a/src/grafito_helpers.cr b/src/grafito_helpers.cr index d18327f..193038e 100644 --- a/src/grafito_helpers.cr +++ b/src/grafito_helpers.cr @@ -1,4 +1,4 @@ -require "kemal" # For HTTP::Server::Context and HTML +require "kemal" # For HTTP::Server::Context and HTML and Kemal::Handler require "json" require "./journalctl" require "./timeline" @@ -7,6 +7,38 @@ require "html_builder" # Regex is part of Crystal core, no explicit require needed for it. module Grafito + # Middleware to shut down the server if it's idle for a given amount of time + private class IdleShutdownHandler < Kemal::Handler + @timeout_sec : Int32 + @channel : Channel(Nil) + @logger : ::Log + def initialize(*, timeout_sec : Int32, logger : ::Log) + @timeout_sec = timeout_sec + @logger = logger + @channel = Channel(Nil).new + spawn(name: "IdleShutdownHandler(#{timeout_sec.to_s}s)") do + # Loop forever, exiting when timeout_sec passes without a new request + loop do + select + when @channel.receive + when timeout(timeout_sec.seconds) + @logger.info { "Shutting down because idle timeout was reached" } + exit 0 + end + end + end + end + + def call(context) + # On a request, reset the idle timer + spawn do + @channel.send(nil) + end + call_next(context) + end + end + + # Helper to build URLs with proper base path handling private def build_url(path : String) : String if Grafito.base_path == "/" diff --git a/src/main.cr b/src/main.cr index 9581b74..751fa49 100644 --- a/src/main.cr +++ b/src/main.cr @@ -39,6 +39,7 @@ require "docopt-config" require "kemal-basic-auth" require "kemal" require "log" +require "socket" # This file [main.cr](main.cr.html) is the starting point for grafito. We get the instructions from the # user about how to start via the command line, using [docopt](https://docopt.org) @@ -69,6 +70,7 @@ Options: -t TIMEZONE, --timezone=TIMEZONE Timezone for timestamps (e.g., America/New_York, Europe/London, GMT+5, local) [default: local]. --base-path=PATH Base path for deployment (e.g., /, /grafito) [default: /]. --user Enable user systemd mode (use journalctl --user and systemctl --user) [default: false]. + --idle-timeout-sec=TIMEOUT Idle timeout in seconds after which to shut down. Primarily useful with systemd socket activation. -h --help Show this screen. --version Show version. @@ -79,6 +81,7 @@ Environment variables: GRAFITO_TIMEZONE Timezone for timestamps (e.g., America/New_York, Europe/London, GMT+5, local) [default: local]. GRAFITO_BASE_PATH Base path for deployment (e.g., /, /grafito) [default: /]. GRAFITO_USER_MODE Enable user systemd mode (true/false) [default: false]. + LISTEN_FDS Used for systemd socket activation. If set to 1, binds to the socket passed as fd 3. DOCOPT # ## The Assets class @@ -125,7 +128,7 @@ def main ENV["LOG_LEVEL"] = log_level Log.setup_from_env - # Port and binding address are important + # Port and binding address are important to open a new server port = args["--port"].to_s.to_i32 bind_address = args["--bind"].to_s @@ -136,6 +139,14 @@ def main Grafito::Log.info { "Restricting to units: #{units.join(", ")}" } end + if args["--idle-timeout-sec"]? + timeout = args["--idle-timeout-sec"].to_s.to_i32 + if timeout > 0 + Grafito.idle_timeout_sec = timeout + Grafito::Log.info { "Will shut down after #{timeout.to_s}s without any requests" } + end + end + # Parse timezone configuration # docopt-config handles the fallback automatically: CLI > env var > config > default timezone = args["--timezone"].to_s @@ -160,9 +171,6 @@ def main # Log at debug level. Probably worth making it configurable. Log.setup(:debug) # Or use Log.setup_from_env for more flexibility - Grafito::Log.info { "Starting Grafito server on #{bind_address}:#{port}" } - # Start kemal listening on the right address - Kemal.config.host_binding = bind_address # Read credentials and realm from environment variables auth_user = ENV["GRAFITO_AUTH_USER"]? @@ -202,11 +210,33 @@ def main baked_asset_handler = BakedFileHandler::BakedFileHandler.new(Assets, mount_path: Grafito.base_path) add_handler baked_asset_handler - # Tell kemal to listen on the right port. That's it. The rest is done in [grafito.cr](grafito.cr.html) + # Check if systemd passed a socket file descriptor to start from + listen_fds = ENV["LISTEN_FDS"]?.to_s.to_i { 0 } + if listen_fds > 1 + Grafito::Log.fatal { "Unexpectedly got more than 1 socket from systemd" } + exit 1 + end + socket_activation = listen_fds == 1 + + # Start kemal. That's it. The rest is done in [grafito.cr](grafito.cr.html) # where the kemal endpoints are defined. # Clear ARGV so Kemal doesn't try to parse command line arguments ARGV.clear - Kemal.run(port: port) + # If systemd socket activation is in use, don't shut down the socket when we exit. + # Systemd manages the lifetime of the socket in that case. + Kemal.run(trap_signal: !socket_activation) do |config| + # The HTTP server is initialized by Kemal before starting this block + server = config.server.not_nil! + if socket_activation + Grafito::Log.info { "Starting Grafito server via systemd socket activation" } + # Start kemal listening on the socket passed by socket activation + server.bind(TCPServer.new(fd: 3)) + else + Grafito::Log.info { "Starting Grafito server on #{bind_address}:#{port}" } + # Start kemal listening on the user-specified address and port + server.bind_tcp(bind_address, port) + end + end end main()