Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/grafito.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
34 changes: 33 additions & 1 deletion src/grafito_helpers.cr
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 == "/"
Expand Down
42 changes: 36 additions & 6 deletions src/main.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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"]?
Expand Down Expand Up @@ -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()
Loading