From 520785e315eddbe47199ac557e88e60eca3ae97c Mon Sep 17 00:00:00 2001 From: Giacomo Leidi Date: Sat, 10 Jan 2026 17:23:44 +0100 Subject: [PATCH] gnu: Add soju-service-type. * gnu/services/messaging.scm (%default-soju-shepherd-requirement): New variable. (soju-ssl-certificate): New configuration record. (soju-database): New configuration record. (soju-configuration): New configuration record. (serialize-soju-configuration,soju-activation,soju-accounts, soju-shepherd-services): New procedures. (soju-service-type): New service. (serialize-ngircd-configuration): Reformat. (pounce-configuration): Reformat. * doc/guix.texi: Document the new soju service. * gnu/tests/messaging.scm: Test the new soju service. Change-Id: I6223ecac1aaaab76bd75461851ffe4cec0678118 --- doc/guix.texi | 214 +++++++++++++++++++++++ gnu/services/messaging.scm | 346 ++++++++++++++++++++++++++++++++++++- gnu/tests/messaging.scm | 112 +++++++++++- 3 files changed, 668 insertions(+), 4 deletions(-) diff --git a/doc/guix.texi b/doc/guix.texi index 12e9d3465a..44873afa2a 100644 --- a/doc/guix.texi +++ b/doc/guix.texi @@ -32001,6 +32001,220 @@ Will be created if it doesn't exist. @end table @end deftp +@subsubheading Soju Service + +@cindex IRC (Internet Relay Chat) +@cindex bouncer, IRC +@cindex Bounced Network Connection, BNC +@url{https://soju.im/, soju} is a user-friendly IRC bouncer. soju connects to +upstream IRC servers on behalf of the user to provide extra functionality. soju +supports many features such as multiple users, numerous +@url{https://ircv3.net/, IRCv3} extensions, chat history playback and detached +channels. It is well-suited for both small and large deployments. + +@defvar soju-service-type +This is the service type for the soju IRC bouncer. Its value is a +@code{soju-configuration} configuration instance, which is documented +below. + +@cindex IRC bouncer configuration for Libera.Chat +@cindex Libera.Chat, IRC bouncer configuration +The following example configures soju to act as an IRC bouncer with an encrypted +connection. + +@lisp +(define %soju-certbot-deploy-hook + (program-file "soju-certbot-deploy-hook.scm" + (with-imported-modules '((gnu services herd)) + #~(begin + (use-modules (gnu services herd) + (with-shepherd-action 'soju ('reload) result result)))))) + +(operating-system + + @dots{} + + (services + (list @dots{} + (service certbot-service-type + (certbot-configuration + (email "your@@email.org") + (certificates + (list + (certificate-configuration + (domains (list "your.domain.org")) + (deploy-hook %soju-certbot-deploy-hook)))))) + + (service soju-service-type + (soju-configuration + (hostname "your.domain.org") + ;; The UNIX socket allows administering the bouncer + ;; from the command line with sojuctl. + (listen '("ircs://" "unix+admin:///var/lib/soju/soju.sock")) + (ssl-certificate + (soju-ssl + (certificate "/etc/certs/your.domain.org/fullchain.pem") + (certificate-key "/etc/certs/your.domain.org/privkey.pem"))) + (title "Your IRC bouncer")))))) +@end lisp + +@end defvar + +@c %start of fragment + +@deftp {Data Type} soju-configuration +Available @code{soju-configuration} fields are: + +@table @asis +@item @code{soju} (default: @code{soju}) (type: package) +Soju package to use for the service. + +@item @code{debug?} (default: @code{#f}) (type: boolean) +Enable debug logging (this will leak sensitive information such as +passwords). This can be overriden at run time with the service command +@code{server debug}. + +@item @code{listen} (default: @code{'(":6697")}) (type: list-of-strings) +Listening URI. The following URIs are supported: + +@itemize + +@item @code{[ircs://][host][:port]} listens with TLS over TCP (default +port if omitted: 6697) +@item @code{irc://localhost[:port]} listens with plain-text over TCP (default +port if omitted: 6667, host must be @code{"localhost"}) +@item @code{irc+insecure://[host][:port]} listens with plain-text over TCP +(default port if omitted: 6667) +@item @code{unix://} listens on a Unix domain socket +@item @code{https://[host][:port]} listens for HTTPS connections (default +port: 443) and handles the following requests: @code{/socket} for +WebSocket and @code{/uploads} (and subdirectories) for file uploads +@item @code{http://localhost[:port]} listens for plain-text HTTP +connections (default port: 80, host must be @code{"localhost"}) and +handles requests like @code{https://} does +@item @code{http+insecure://[host][:port]} listens for plain-text HTTP +connections (default port: 80) and handles requests like @code{https://} +does @item @code{http+unix://} listens for plain-text HTTP +connections on a Unix domain socket and handles requests like +@code{https://} does +@item @code{wss://[host][:port]} listens for WebSocket connections over TLS +(default port: 443) +@item @code{ws://localhost[:port]} listens for plain-text WebSocket connections +(default port: 80, host must be @code{"localhost"}) +@item @code{ws+insecure://[host][:port]} listens for plain-text WebSocket +connections (default port: 80) +@item @code{ws+unix://} listens for plain-text WebSocket connections on a +Unix domain socket +@item @code{ident://[host][:port]} listens for plain-text ident connections +(default port: 113) +@item @code{http+prometheus://localhost:} listens for plain-text HTTP +connections and serves Prometheus metrics (host must be @code{"localhost"}) +@item @code{http+pprof://localhost:} listens for plain-text HTTP +connections and serves pprof runtime profiling data (host must be +@code{"localhost"}). For more information, see +@uref{https://pkg.go.dev/net/http/pprof,upstream} documentation +@item @code{unix+admin://[path]} listens on a Unix domain socket for +administrative connections, such as sojuctl (default path: +@code{/run/soju/admin}) + +@end itemize + +If the scheme is omitted, @code{ircs} is assumed. If multiple @code{listen} +values are specified, soju will listen on each of them. + +@item @code{hostname} (type: maybe-string) +Server hostname, it defaults to the system hostname. This should be set +to a fully qualified domain name. + +@item @code{title} (type: string) +Server title. This will be sent as the @code{ISUPPORT NETWORK} value +when clients don't select a specific network. + +@item @code{ssl-certificate} (type: maybe-soju-ssl) +Where to find the private key for secure connections. If set, this field +will have the service run under root privileges. + +@item @code{shepherd-requirement} (default: @code{'(user-processes loopback)}) (type: list) +A list of Shepherd services to use. Add extra dependencies to +@code{%default-soju-shepherd-requirement} to extend its value. + +@item @code{db} (default: @code{(soju-database)}) (type: soju-database) +The database where soju will write its state. + +@item @code{log-file} (default: @code{"/var/log/soju.log"}) (type: string) +The name of the file where soju will write its logs. + +@item @code{extra-content} (default: @code{'()}) (type: text-config) +Extra content to append to the configuration as-is. + +@end table + +@end deftp + +@c %end of fragment + +@c %start of fragment + +@deftp {Data Type} soju-ssl +Available @code{soju-ssl} fields are: + +@table @asis + +@item @code{certificate} (type: string) +Where to find the certificate for secure connections. + +@item @code{key} (type: string) +Where to find the private key for secure connections. + +@end table + +@end deftp + + +@c %end of fragment + +@c %start of fragment + +@deftp {Data Type} soju-database +Available @code{soju-database} fields are: + +@table @asis + +@item @code{datadir} (default: @code{"/var/lib/soju"}) (type: string) +The name of the directory where soju will write its state. + +@item @code{driver} (default: @code{'sqlite3}) (type: symbol) +Set the database driver for user, network and channel storage. Supported +drivers: + +@itemize +@item @code{'sqlite3} +@item @code{'postgres} +@end itemize + +@item @code{source} (type: maybe-string) +Set the database location for user, network and channel storage. By +default, a sqlite3 database is opened in the directory specified in the +@code{datadir} field. In general the driver expect the following: + +@itemize +@item @code{'sqlite3} expects source to be a path to +the SQLite file +@item @code{'postgres} expects source to be a +space-separated list of @code{key=value} parameters, e.g. +@code{"host=/run/postgresql dbname=soju"}. Note that @code{sslmode +defaults} to @code{require}. For more information on connection +strings, see +@uref{https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters,upstream} +'s documentation. +@end itemize +@end table + +@end deftp + + +@c %end of fragment + @node Telephony Services @subsection Telephony Services diff --git a/gnu/services/messaging.scm b/gnu/services/messaging.scm index 1fbb1f1d85..ab89338969 100644 --- a/gnu/services/messaging.scm +++ b/gnu/services/messaging.scm @@ -5,6 +5,7 @@ ;;; Copyright © 2018 Pierre-Antoine Rouby ;;; Copyright © 2025 Maxim Cournoyer ;;; Copyright © 2024 Evgeny Pisemsky +;;; Copyright © 2026 Giacomo Leidi ;;; ;;; This file is part of GNU Guix. ;;; @@ -30,12 +31,15 @@ #:autoload (gnu packages rust-apps) (mollysocket) #:use-module (gnu packages tls) #:use-module (gnu services) + #:use-module (gnu services admin) #:use-module (gnu services shepherd) #:use-module (gnu services configuration) #:use-module (gnu system shadow) #:autoload (gnu build linux-container) (%namespaces) #:use-module ((gnu system file-systems) #:select (file-system-mapping)) + #:use-module (guix diagnostics) #:use-module (guix gexp) + #:use-module (guix i18n) #:use-module (guix modules) #:use-module (guix records) #:use-module (guix packages) @@ -208,7 +212,43 @@ mollysocket-configuration-allowed-uuids mollysocket-configuration-db mollysocket-configuration-vapid-key-file - mollysocket-service-type)) + mollysocket-service-type + + %default-soju-shepherd-requirement + + soju-ssl + soju-ssl? + soju-ssl-fields + soju-ssl-certificate + soju-ssl-key + + soju-database + soju-database? + soju-database-fields + soju-database-datadir + soju-database-driver + soju-database-source + + soju-configuration + soju-configuration? + soju-configuration-fields + soju-configuration-soju + soju-configuration-debug? + soju-configuration-listen + soju-configuration-hostname + soju-configuration-title + soju-configuration-ssl-certificate + soju-configuration-log-file + soju-configuration-shepherd-requirement + soju-configuration-database + soju-configuration-extra-content + + soju-configuration->mixed-text-file + soju-activation + soju-accounts + soju-shepherd-services + + soju-service-type)) ;;; Commentary: ;;; @@ -1570,7 +1610,7 @@ values." ;; Ensure stdin is not connected to a TTY source to avoid ngircd ;; configtest blocking with a confirmation prompt. (parameterize ((current-input-port (%make-void-port "r"))) - (invoke #+ngircd "--config" #$ngircd.conf "--configtest" )) + (invoke #+ngircd "--config" #$ngircd.conf "--configtest")) (copy-file #$ngircd.conf #$output)))))) (define (ngircd-wrapper config) @@ -1804,7 +1844,7 @@ reconnecting client. The size must be a power of two.") maybe-string "Host to bind the @emph{source} address to when connecting to the server. To connect from any address over IPv4 only, use @samp{0.0.0.0}. To connect -from any address over IPv6 only, use @samp{::}." ) +from any address over IPv6 only, use @samp{::}.") (host string @@ -2388,3 +2428,303 @@ if it does not exist.") mollysocket-activation-service))) (default-value (mollysocket-configuration)) (description "UnifiedPush provider for the Signal client Molly."))) + + +;;; +;;; Soju +;;; + +(define %default-soju-shepherd-requirement + '(user-processes loopback)) + +(define-configuration soju-ssl + (certificate + (string) + "Where to find the certificate for secure connections." + (serializer empty-serializer)) + (key + (string) + "Where to find the private key for secure connections." + (serializer empty-serializer))) + +(define (soju-serialize-soju-ssl name value) + #~(string-append "tls " + #$(soju-ssl-certificate value) + " " + #$(soju-ssl-key value) + "\n")) + +(define-maybe soju-ssl (prefix soju-)) + +(define (soju-sanitize-db-driver value) + (if (or (eq? value 'sqlite3) + (eq? value 'postgres)) + value + (raise + (formatted-message + (G_ "db-driver can be either 'sqlite3 or 'postgres but ~a was found") + value)))) + +(define (soju-serialize-symbol name value) (symbol->string value)) + +(define (soju-serialize-string name value) + (define quoted-value + #~(if (string-contains #$value " ") + (string-append "\"" #$value "\"") + #$value)) + #~(string-append (symbol->string '#$name) " " #$quoted-value "\n")) + +(define soju-serialize-package serialize-package) + +(define-maybe string (prefix soju-)) + +(define-configuration soju-database + (datadir + (string "/var/lib/soju") + "The name of the directory where soju will write its state.") + (driver + (symbol 'sqlite3) + "Set the database driver for user, network and channel storage. + +Supported drivers: + +@itemize +@item @code{'sqlite3} +@item @code{'postgres} +@end itemize" + (sanitizer soju-sanitize-db-driver) + (serializer soju-serialize-symbol)) + (source + (maybe-string) + "Set the database location for user, network and channel storage. By +default, a sqlite3 database is opened in the directory specified in the +@code{datadir} field. + +In general the driver expect the following: + +@itemize +@item @code{'sqlite3} expects source to be a path to the SQLite file +@item @code{'postgres} expects source to be a space-separated list of +@code{key=value} parameters, e.g. @code{\"host=/run/postgresql dbname=soju\"}. +Note that @code{sslmode defaults} to @code{require}. For more information on +connection strings, see +@url{https://pkg.go.dev/github.com/lib/pq#hdr-Connection_String_Parameters, +upstream}'s documentation. +@end itemize" + (serializer soju-serialize-maybe-string))) + +(define (soju-db-source driver source datadir) + (if (not (string=? "" source)) + source + (if (string=? driver "sqlite3") + (string-append datadir "/soju.db") + (raise + (G_ "db-source can't be empty when db-driver is set to 'postgres. +Make sure to pass a connection string"))))) + +(define (soju-serialize-soju-database name config) + (define fields + (filter-configuration-fields + soju-database-fields + '(driver source))) + (define getters + (map configuration-field-getter fields)) + (define names + (map configuration-field-name fields)) + (define serializers + (map configuration-field-serializer fields)) + (define values + (map (match-lambda ((serializer name getter) + (serializer name (getter config)))) + (zip serializers names getters))) + + #~(string-append "db " #$(first values) + " " #$(soju-db-source + (first values) + (second values) + (soju-database-datadir config)) + "\n")) + +(define (soju-serialize-list-of-strings name value) + #~(string-append + (string-join (map (lambda (l) (string-append "listen " l)) + (list #$@value)) + "\n") + "\n")) + + +(define soju-serialize-text-config serialize-text-config) + +(define-configuration soju-configuration + (soju + (package soju) + "Soju package to use for the service." + (serializer empty-serializer)) + (debug? + (boolean #f) + "Enable debug logging (this will leak sensitive information such as +passwords). This can be overriden at run time with the service command +@code{server debug}." + (serializer empty-serializer)) + (listen + (list-of-strings '(":6697")) + "Listening URI. The following URIs are supported: + +@itemize + +@item @code{[ircs://][host][:port]} listens with TLS over TCP (default port if +omitted: 6697) +@item @code{irc://localhost[:port]} listens with plain-text over TCP (default +port if omitted: 6667, host must be @code{\"localhost\"}) +@item @code{irc+insecure://[host][:port]} listens with plain-text over TCP +(default port if omitted: 6667) +@item @code{unix://} listens on a Unix domain socket +@item @code{https://[host][:port]} listens for HTTPS connections (default port: +443) and handles the following requests: @code{/socket} for WebSocket and +@code{/uploads} (and subdirectories) for file uploads +@item @code{http://localhost[:port]} listens for plain-text HTTP connections +(default port: 80, host must be @code{\"localhost\"}) and handles requests like +@code{https://} does +@item @code{http+insecure://[host][:port]} listens for plain-text HTTP +connections (default port: 80) and handles requests like @code{https://} does +@item @code{http+unix://} listens for plain-text HTTP connections on a +Unix domain socket and handles requests like @code{https://} does +@item @code{wss://[host][:port]} listens for WebSocket connections over TLS +(default port: 443) +@item @code{ws://localhost[:port]} listens for plain-text WebSocket connections +(default port: 80, host must be @code{\"localhost\"}) +@item @code{ws+insecure://[host][:port]} listens for plain-text WebSocket +connections (default port: 80) +@item @code{ws+unix://} listens for plain-text WebSocket connections on a +Unix domain socket +@item @code{ident://[host][:port]} listens for plain-text ident connections +(default port: 113) +@item @code{http+prometheus://localhost:} listens for plain-text HTTP +connections and serves Prometheus metrics (host must be @code{\"localhost\"}) +@item @code{http+pprof://localhost:} listens for plain-text HTTP +connections and serves pprof runtime profiling data (host must be +@code{\"localhost\"}). For more information, see +@url{https://pkg.go.dev/net/http/pprof,upstream} documentation +@item @code{unix+admin://[path]} listens on a Unix domain socket for +administrative connections, such as sojuctl (default path: +@code{/run/soju/admin}) + +@end itemize + +If the scheme is omitted, @code{ircs} is assumed. If multiple @code{listen} +values are specified, soju will listen on each of them.") + (hostname + (maybe-string) + "Server hostname, it defaults to the system hostname. This should be set to +a fully qualified domain name.") + (title + (string) + "Server title. This will be sent as the @code{ISUPPORT NETWORK} value when +clients don't select a specific network.") + (ssl-certificate + (maybe-soju-ssl) + "Where to find the private key for secure connections. If set, this field +will have the service run under root privileges.") + (shepherd-requirement + (list %default-soju-shepherd-requirement) + "A list of Shepherd services to use. Add extra dependencies to +@code{%default-soju-shepherd-requirement} to extend its value." + (serializer empty-serializer)) + (log-file + (string "/var/log/soju.log") + "The name of the file where soju will write its logs." + (serializer empty-serializer)) + (db + (soju-database (soju-database)) + "The database where soju will write its state.") + (extra-content + (text-config '()) + "Extra content to append to the configuration as-is.") + (prefix soju-)) + +(define (soju-needs-privileges? config) + ;; NOTE: Certificates on the Guix System are only readable by root. In case a + ;; certificate and a private key are passed no unprivileged users will be + ;; added to the system. + (maybe-value-set? + (soju-configuration-ssl-certificate config))) + +(define (serialize-soju-configuration config) + (mixed-text-file "soju.conf" + (serialize-configuration + config + (filter-configuration-fields + soju-configuration-fields + '(soju debug? shepherd-requirement log-file) + #t)))) + +(define (soju-activation config) + (let ((datadir (soju-database-datadir + (soju-configuration-db config))) + (user-name + (if (soju-needs-privileges? config) "root" "soju"))) + #~(let* ((user (getpwnam #$user-name)) + (uid (passwd:uid user)) + (gid (passwd:gid user)) + (datadir #$datadir)) + ;; Setup datadir + (mkdir-p datadir) + (chown datadir uid gid) + (for-each (lambda (f) (chown f uid gid)) + (find-files datadir ".*"))))) + +(define (soju-accounts config) + (if (soju-needs-privileges? config) + '() + (list (user-group (name "soju") (system? #t)) + (user-account + (name "soju") + (group "soju") + (system? #t) + (comment "soju server user") + (home-directory "/var/empty") + (shell (file-append shadow "/sbin/nologin")))))) + +(define (soju-shepherd-services config) + (match-record config + (soju debug? log-file shepherd-requirement) + (let ((user-name (if (soju-needs-privileges? config) "root" "soju")) + (soju-binary (file-append soju "/bin/soju")) + (config-file (serialize-soju-configuration config))) + (list (shepherd-service + (provision '(soju)) + (documentation "Run the soju daemon.") + (requirement shepherd-requirement) + (start #~(make-forkexec-constructor + (list #$soju-binary + #$@(if debug? '("-debug") '()) + "-config" #$config-file) + #:log-file #$log-file + #:user #$user-name + #:group #$user-name)) + (stop #~(make-kill-destructor)) + (actions + (list + (shepherd-action + (name 'reload) + (documentation "Reload soju configuration file and restart +it. It is useful for situations where the same soju configuration file can +point to different things after a reload, such as renewed TLS certificates.") + (procedure + #~(lambda (process . args) + (kill (process-id process) SIGHUP)))) + (shepherd-configuration-action config-file)))))))) + +(define soju-service-type + (service-type (name 'soju) + (extensions + (list (service-extension shepherd-root-service-type + soju-shepherd-services) + (service-extension log-rotation-service-type + (compose + list soju-configuration-log-file)) + (service-extension activation-service-type + soju-activation) + (service-extension account-service-type + soju-accounts))) + (description "Run the soju IRC bouncer."))) diff --git a/gnu/tests/messaging.scm b/gnu/tests/messaging.scm index 83ccca8891..559dc81a74 100644 --- a/gnu/tests/messaging.scm +++ b/gnu/tests/messaging.scm @@ -4,6 +4,7 @@ ;;; Copyright © 2018 Efraim Flashner ;;; Copyright © 2025 Maxim Cournoyer ;;; Copyright © 2025 Evgeny Pisemsky +;;; Copyright © 2026 Giacomo Leidi ;;; ;;; This file is part of GNU Guix. ;;; @@ -44,7 +45,8 @@ %test-ngircd %test-pounce %test-quassel - %test-mosquitto)) + %test-mosquitto + %test-soju)) (define (run-xmpp-test name xmpp-service pid-file create-account) "Run a test of an OS running XMPP-SERVICE, which writes its PID to PID-FILE." @@ -669,3 +671,111 @@ OPENSSL:localhost:7000,verify=0 &") (name "mosquitto") (description "Test a running Mosquitto MQTT broker.") (value (run-mosquitto-test)))) + + +;;; +;;; soju. +;;; + +(define %soju-os + (operating-system + (inherit %simple-os) + (packages (cons* soju %base-packages)) + (services + (cons* + (service dhcpcd-service-type) + (service soju-service-type + (soju-configuration + (debug? #t) + (listen '("irc://localhost" "unix+admin:///var/lib/soju/soju.sock")) + (title "soju IRC bouncer") + (extra-content + (list (plain-file "soju.conf" "hostname test.example.org"))))) + %base-services)))) + +(define (run-soju-test) + (define vm + (virtual-machine + (operating-system + (marionette-operating-system + %soju-os + #:imported-modules (source-module-closure + '((gnu services herd))))))) + + (define test + (with-imported-modules '((gnu build marionette)) + #~(begin + (use-modules (srfi srfi-64) + (gnu build marionette)) + + (define marionette + (make-marionette (list #$vm))) + + (test-runner-current (system-test-runner #$output)) + (test-begin "soju") + + (marionette-eval + '(begin + (use-modules (gnu services herd)) + (wait-for-service 'user-processes)) + marionette) + + (test-equal "soju configuration file is well formed" + "listen irc://localhost +listen unix+admin:///var/lib/soju/soju.sock +title \"soju IRC bouncer\" +db sqlite3 /var/lib/soju/soju.db +hostname test.example.org +" + (marionette-eval + '(begin + (use-modules (ice-9 popen) + (ice-9 rdelim) + (ice-9 textual-ports)) + (let* ((port (open-input-pipe "herd configuration soju")) + (soju.conf (string-trim-both (read-line port)))) + (close-pipe port) + (call-with-input-file soju.conf get-string-all))) + marionette)) + + (test-assert "soju service runs" + (marionette-eval + '(begin + (use-modules (gnu services herd)) + (wait-for-service 'soju)) + marionette)) + + (test-assert "soju listens on TCP port 6667" + (wait-for-tcp-port 6667 marionette)) + + (test-equal "sojuctl can create a user" + "fishinthecalculator (admin): 0 networks" + (marionette-eval + '(begin + (use-modules (ice-9 popen) + (ice-9 rdelim)) + (system (string-join + '("sojuctl" "-config" "$(herd configuration soju)" + "user" "create" "-admin" + "-username" "fishinthecalculator" + "-password" "1234") + " ")) + (let* ((port (open-input-pipe (string-join + '("sojuctl" "-config" + "$(herd configuration soju)" + "user" "status") + " "))) + (msg (read-line port))) + (close-pipe port) + msg)) + marionette)) + + (test-end)))) + + (gexp->derivation "soju-test" test)) + +(define %test-soju + (system-test + (name "soju") + (description "Run a soju IRC bouncer.") + (value (run-soju-test))))