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()