#!/bin/sh /etc/rc.common # shellcheck shell=busybox # shellcheck disable=SC2034,SC2329 # # WeeChat headless daemon service for OpenWrt. # # Configuration Persistence: # UCI (/etc/config/weechat) is authoritative for irc.conf, relay.conf, # and logger.conf. These are regenerated from UCI on service start/restart. # Runtime changes made via WeeChat (/set, /server add) to these subsystems # will be overwritten on service restart. # Other WeeChat configuration files (weechat.conf, plugins.conf, sec.conf) # and user data in /etc/weechat/data are fully persistent. # # Directory layout: # /etc/weechat root:root 0755 # /etc/weechat/config root:weechat 1770 WeeChat configuration # /etc/weechat/data weechat 0700 WeeChat data # /etc/weechat/tls root:root 0755 relay certificates # /var/run/weechat root:root 0755 # /var/run/weechat/cache weechat 0700 # /var/run/weechat/runtime weechat 0700 # # The weechat user cannot modify the parent of any directory this script # changes, so it cannot swap one for a symlink. The config directory is # sticky, so the daemon cannot replace files that root is still writing. # Of all log directories, root only creates the default /var/log/weechat. USE_PROCD=1 START=90 STOP=10 PROG=/usr/bin/weechat-headless USER="weechat" GROUP="weechat" BASE_DIR="${WEECHAT_BASE_DIR:-/etc/weechat}" RUN_DIR="${WEECHAT_RUN_DIR:-/var/run/weechat}" CONFIG_DIR="$BASE_DIR/config" DATA_DIR="$BASE_DIR/data" TLS_DIR="$BASE_DIR/tls" CACHE_DIR="$RUN_DIR/cache" RUNTIME_DIR="$RUN_DIR/runtime" LOG_DIR="${WEECHAT_LOG_DIR:-/var/log/weechat}" NL=' ' log_err() { logger -t weechat -p daemon.err "$*" } # WeeChat config files are line based and only strip the outer quotes, # so a value is safe inside "..." as long as it has no newline. check_line() { case "$2" in *"$NL"*) log_err "option '$1' must not contain a newline" return 1 ;; esac } # WeeChat evaluates most string options, so ${...} in them would be # expanded. Secrets avoid that with weechat_secret(), other values must # not contain it. check_value() { check_line "$1" "$2" || return 1 # shellcheck disable=SC2016 case "$2" in *'${'*) log_err "option '$1' must not contain '\${'" return 1 ;; esac } check_port() { case "$2" in ''|*[!0-9]*) ;; *) [ "$2" -ge 1 ] && [ "$2" -le 65535 ] && return 0 ;; esac log_err "option '$1' must be a port number, got '$2'" return 1 } # A dotted IPv4 address as inet_pton() takes it, without leading zeros check_ipv4() { local IFS=. part n=0 case "$1" in .*|*.|*..*) return 1 ;; esac for part in $1; do case "$part" in [0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]) n=$((n + 1)) ;; *) return 1 ;; esac done [ "$n" -eq 4 ] } # An IPv6 address as inet_pton() takes it, the last 32 bits may be a # dotted IPv4 address check_ipv6() { local addr="$1" head tail part groups=0 max=8 IFS=: case "$addr" in *[!0-9A-Fa-f.:]*|*:::*|*::*::*) return 1 ;; esac case "$addr" in *.*) check_ipv4 "${addr##*:}" || return 1 addr="${addr%:*}:0:0" ;; esac case "$addr" in *.*) return 1 ;; *::*) head="${addr%%::*}" tail="${addr#*::}" max=7 ;; *) head="$addr" tail= ;; esac case "$head" in :*|*:) return 1 ;; esac case "$tail" in :*|*:) return 1 ;; esac for part in $head $tail; do case "$part" in ?|??|???|????) groups=$((groups + 1)) ;; *) return 1 ;; esac done if [ "$max" -eq 8 ]; then [ "$groups" -eq 8 ] else [ "$groups" -le 7 ] fi } # A value that is empty or only made of spaces and tabs is_blank() { [ -z "$(printf '%s' "$1" | tr -d ' \t')" ] } check_path() { check_value "$1" "$2" || return 1 case "$2" in */.|*/./*|*/..|*/../*) ;; /*) return 0 ;; esac log_err "option '$1' must be an absolute path without . or .. components, got '$2'" return 1 } # Wrap a secret in a WeeChat expression that decodes it at runtime. # The hex digits cannot be split on ';', evaluated as ${...} or break # the quoting in the config file, whatever the secret contains. weechat_secret() { # shellcheck disable=SC2016 printf '${base_decode:16,%s}' "$(printf '%s' "$1" | hexdump -v -e '/1 "%02x"')" } # Create or fix a directory. Only use it on paths whose parent the # weechat user cannot write, otherwise chmod could follow a symlink. ensure_dir() { local dir="$1" owner="$2" mode="$3" if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then log_err "'$dir' exists but is not a directory, refusing to start" return 1 fi [ -d "$dir" ] || mkdir -m "$mode" "$dir" || return 1 chown -h "$owner" "$dir" || return 1 chmod "$mode" "$dir" } # Create the default log directory. Root does not create any other one, # because a directory owned by the daemon could then end up in a place # where root reads or runs files, for example /etc/hotplug.d. The parent # of the default one is only writable by root. The modes are set through # umask, so nothing follows a symlink. create_log_dir() { ( umask 022 && mkdir -p "${LOG_DIR%/*}" ) || return 1 ( umask 027 && mkdir "$LOG_DIR" ) || return 1 chown -h "$USER:$GROUP" "$LOG_DIR" } # Whether $2 is a regular file with the content of $1, owned by the daemon # and only readable by it conf_is_current() { [ -f "$2" ] && [ ! -L "$2" ] && cmp -s "$1" "$2" || return 1 # shellcheck disable=SC2046 set -- $(ls -l "$2") [ "$1" = "-rw-------" ] && [ "$3" = "$USER" ] && [ "$4" = "$GROUP" ] } install_conf() { local src="$1" dst="$2" tmp_dst # Anything else than the right file, also one whose mode or owner was # changed, is replaced by a new file. Fixing it with chmod or chown # could follow a symlink the daemon put in its place. conf_is_current "$src" "$dst" && return 0 # The temporary file stays owned by root until it is renamed, so in # the sticky config directory the daemon cannot touch it meanwhile. # A symlink planted there by the daemon is removed first. mv -T renames # onto $dst itself, a plain mv would move the file into a directory # that such a symlink points to. tmp_dst="$(mktemp "${dst}.XXXXXX")" || return 1 if cat "$src" > "$tmp_dst" && chmod 0600 "$tmp_dst" && { [ ! -L "$dst" ] || rm -f "$dst"; } && mv -f -T "$tmp_dst" "$dst"; then chown -h "$USER:$GROUP" "$dst" return fi rm -f "$tmp_dst" return 1 } validate_server() { local s="$1" local s_enabled address port ipv6 auth_method password opt val nicks username sasl_user config_get_bool s_enabled "$s" enabled 1 [ "$s_enabled" -eq 1 ] || return 0 config_get address "$s" address '' if [ -z "$address" ]; then log_err "server '$s': option address is missing" return 1 fi # The section name is the WeeChat server name. Anonymous sections get # a name from their position, which changes when sections are removed. case "$s" in cfg[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) log_err "server section '$s' has no name, give it one (config server 'libera')" return 1 ;; esac case "$address" in *[!A-Za-z0-9._:-]*) log_err "server '$s': address '$address' must be a single host name or IP address" return 1 ;; esac config_get port "$s" port '6697' check_port "$s.port" "$port" || return 1 config_get ipv6 "$s" ipv6 'auto' case "$ipv6" in auto|force|disable) ;; *) log_err "server '$s': ipv6 must be auto, force or disable, got '$ipv6'" return 1 ;; esac for opt in nicks username realname autojoin sasl_username; do config_get val "$s" "$opt" '' check_value "$s.$opt" "$val" || return 1 done config_get auth_method "$s" auth_method 'none' config_get password "$s" password '' check_line "$s.password" "$password" || return 1 case "$auth_method" in none) ;; nickserv|sasl_plain|sasl_scram_sha_256|sasl_scram_sha_512) if is_blank "$password"; then log_err "server '$s': auth_method '$auth_method' needs a password" return 1 fi ;; *) log_err "server '$s': unknown auth_method '$auth_method'" return 1 ;; esac case "$auth_method" in nickserv) # WeeChat replaces these in the command after decoding the password # shellcheck disable=SC2016 case "$password" in *'$nick'*|*'$channel'*|*'$server'*) log_err "server '$s': a NickServ password cannot contain \$nick, \$channel or \$server, use SASL instead" return 1 ;; esac ;; sasl_*) # Same default as in handle_server_config() config_get nicks "$s" nicks '' config_get username "$s" username '' sasl_user="${nicks%%,*}" config_get sasl_user "$s" sasl_username "${sasl_user:-$username}" if is_blank "$sasl_user"; then log_err "server '$s': SASL needs sasl_username or a nickname" return 1 fi ;; esac } validate_config() { local relay_enabled relay_address relay_port relay_password relay_tls relay_cert_key local log_enabled log_dir server_errors relay_address_ok config_get_bool relay_enabled weechat relay_enabled 0 config_get relay_address weechat relay_address '' config_get relay_port weechat relay_port '9000' config_get relay_password weechat relay_password '' config_get_bool relay_tls weechat relay_tls 0 config_get relay_cert_key weechat relay_cert_key '' config_get_bool log_enabled weechat log_enabled 0 config_get log_dir weechat log_dir "$LOG_DIR" # WeeChat does not listen at all if it cannot parse the bind address, # so refuse what inet_pton() would refuse, like leading zeros case "$relay_address" in '') relay_address_ok=1 ;; *:*) check_ipv6 "$relay_address" && relay_address_ok=1 || relay_address_ok=0 ;; *) check_ipv4 "$relay_address" && relay_address_ok=1 || relay_address_ok=0 ;; esac if [ "$relay_address_ok" -ne 1 ]; then log_err "option 'relay_address' must be an IPv4 or IPv6 address, got '$relay_address'" return 1 fi check_line relay_password "$relay_password" || return 1 [ -z "$relay_cert_key" ] || check_path relay_cert_key "$relay_cert_key" || return 1 check_path log_dir "$log_dir" || return 1 case "$log_dir" in *%*) log_err "option 'log_dir' must not contain '%'" return 1 ;; esac # Security: relay must not run without authentication if [ "$relay_enabled" -eq 1 ]; then check_port relay_port "$relay_port" || return 1 if is_blank "$relay_password"; then log_err "relay enabled but no relay_password configured, refusing to start" return 1 fi if [ "$relay_tls" -eq 1 ]; then [ -n "$relay_cert_key" ] || relay_cert_key="$TLS_DIR/relay.pem" if [ ! -f "$relay_cert_key" ]; then log_err "TLS relay enabled but '$relay_cert_key' does not exist, refusing to start" return 1 fi fi fi # Secrets are hex encoded with hexdump, see weechat_secret() if ! command -v hexdump >/dev/null; then log_err "hexdump is required to write WeeChat passwords" return 1 fi # config_foreach only returns the status of the last section server_errors=0 validate_server_cb() { validate_server "$1" || server_errors=1 } config_foreach validate_server_cb server [ "$server_errors" -eq 0 ] } prepare_dirs() { local relay_enabled relay_tls relay_cert_key log_enabled log_dir # Parents first, so the daemon has lost write access to them before # any of their entries are inspected. ensure_dir "$BASE_DIR" root:root 0755 || return 1 ensure_dir "$CONFIG_DIR" "root:$GROUP" 1770 || return 1 ensure_dir "$DATA_DIR" "$USER:$GROUP" 0700 || return 1 ensure_dir "$TLS_DIR" root:root 0755 || return 1 ensure_dir "$RUN_DIR" root:root 0755 || return 1 ensure_dir "$CACHE_DIR" "$USER:$GROUP" 0700 || return 1 ensure_dir "$RUNTIME_DIR" "$USER:$GROUP" 0700 || return 1 # Let the daemon read, but not replace, a certificate in the managed directory config_get_bool relay_enabled weechat relay_enabled 0 config_get_bool relay_tls weechat relay_tls 0 config_get relay_cert_key weechat relay_cert_key "$TLS_DIR/relay.pem" if [ "$relay_enabled" -eq 1 ] && [ "$relay_tls" -eq 1 ] && [ "${relay_cert_key%/*}" = "$TLS_DIR" ] && [ -f "$relay_cert_key" ] && [ ! -L "$relay_cert_key" ]; then chown -h "root:$GROUP" "$relay_cert_key" || return 1 chmod 0640 "$relay_cert_key" || return 1 fi # A missing log directory only costs the chat logs, not the service config_get_bool log_enabled weechat log_enabled 0 config_get log_dir weechat log_dir "$LOG_DIR" if [ "$log_enabled" -eq 1 ] && [ ! -d "$log_dir" ]; then if [ "${log_dir%/}" = "$LOG_DIR" ]; then create_log_dir || log_err "could not create log directory '$LOG_DIR'" else log_err "log directory '$log_dir' does not exist, create it and make it writable for the weechat user" fi fi return 0 } generate_weechat_configs() { local relay_enabled relay_address relay_port relay_tls relay_cert_key relay_password local log_enabled log_dir tmp_dir cfg family config_get_bool relay_enabled weechat relay_enabled 0 config_get relay_address weechat relay_address '' config_get relay_port weechat relay_port '9000' config_get_bool relay_tls weechat relay_tls 0 config_get relay_cert_key weechat relay_cert_key '' config_get relay_password weechat relay_password '' config_get_bool log_enabled weechat log_enabled 0 config_get log_dir weechat log_dir "$LOG_DIR" tmp_dir="$(mktemp -d /tmp/weechat_cfg.XXXXXX)" [ -n "$tmp_dir" ] && [ -d "$tmp_dir" ] || return 1 trap 'rm -rf "$tmp_dir"' EXIT INT TERM # 1. logger.conf cat < "$tmp_dir/logger.conf" # logger.conf - generated from /etc/config/weechat [file] auto_log = $( [ "$log_enabled" -eq 1 ] && echo "on" || echo "off" ) flush_delay = 120 path = "$log_dir" EOF # 2. relay.conf cat < "$tmp_dir/relay.conf" # relay.conf - generated from /etc/config/weechat config_version = 2 [network] EOF [ -n "$relay_address" ] && printf 'bind_address = "%s"\n' "$relay_address" >> "$tmp_dir/relay.conf" [ -n "$relay_password" ] && printf 'password = "%s"\n' "$(weechat_secret "$relay_password")" >> "$tmp_dir/relay.conf" if [ "$relay_tls" -eq 1 ]; then printf 'tls_cert_key = "%s"\n' "${relay_cert_key:-$TLS_DIR/relay.pem}" >> "$tmp_dir/relay.conf" fi # WeeChat listens on IPv4 and IPv6 by default and refuses to start the # relay when the bind address does not fit both, so pin the family. # An IPv4-mapped IPv6 address only binds on the dual-stack socket. case "$relay_address" in ''|*:*.*) family='' ;; *:*) family='ipv6.' ;; *) family='ipv4.' ;; esac echo "[port]" >> "$tmp_dir/relay.conf" if [ "$relay_enabled" -eq 1 ] && [ -n "$relay_port" ]; then if [ "$relay_tls" -eq 1 ]; then echo "${family}tls.weechat = $relay_port" >> "$tmp_dir/relay.conf" else echo "${family}weechat = $relay_port" >> "$tmp_dir/relay.conf" fi fi # 3. irc.conf cat < "$tmp_dir/irc.conf" # irc.conf - generated from /etc/config/weechat config_version = 5 [server_default] [server] EOF handle_server_config() { local s_name="$1" local s_enabled address port ssl ipv6 autoconnect nicks username realname auth_method password autojoin config_get_bool s_enabled "$s_name" enabled 1 [ "$s_enabled" -eq 1 ] || return 0 config_get address "$s_name" address '' [ -n "$address" ] || return 0 config_get port "$s_name" port '6697' config_get_bool ssl "$s_name" ssl 1 config_get ipv6 "$s_name" ipv6 'auto' config_get_bool autoconnect "$s_name" autoconnect 1 config_get nicks "$s_name" nicks '' config_get username "$s_name" username '' config_get realname "$s_name" realname '' config_get auth_method "$s_name" auth_method 'none' config_get password "$s_name" password '' config_get autojoin "$s_name" autojoin '' { printf '%s.addresses = "%s/%s"\n' "$s_name" "$address" "$port" printf '%s.tls = %s\n' "$s_name" "$( [ "$ssl" -eq 1 ] && echo "on" || echo "off" )" printf '%s.autoconnect = %s\n' "$s_name" "$( [ "$autoconnect" -eq 1 ] && echo "on" || echo "off" )" [ -n "$ipv6" ] && printf '%s.ipv6 = %s\n' "$s_name" "$ipv6" [ -n "$nicks" ] && printf '%s.nicks = "%s"\n' "$s_name" "$nicks" [ -n "$username" ] && printf '%s.username = "%s"\n' "$s_name" "$username" [ -n "$realname" ] && printf '%s.realname = "%s"\n' "$s_name" "$realname" [ -n "$autojoin" ] && printf '%s.autojoin = "%s"\n' "$s_name" "$autojoin" case "$auth_method" in nickserv) if [ -n "$password" ]; then printf '%s.command = "/msg nickserv identify %s"\n' "$s_name" "$(weechat_secret "$password")" fi ;; sasl_plain|sasl_scram_sha_256|sasl_scram_sha_512) if [ -n "$password" ]; then local mech="plain" [ "$auth_method" = "sasl_scram_sha_256" ] && mech="scram-sha-256" [ "$auth_method" = "sasl_scram_sha_512" ] && mech="scram-sha-512" # The account defaults to the first nickname local sasl_user="${nicks%%,*}" config_get sasl_user "$s_name" sasl_username "${sasl_user:-$username}" printf '%s.sasl_mechanism = %s\n' "$s_name" "$mech" printf '%s.sasl_username = "%s"\n' "$s_name" "$sasl_user" printf '%s.sasl_password = "%s"\n' "$s_name" "$(weechat_secret "$password")" fi ;; esac } >> "$tmp_dir/irc.conf" } config_foreach handle_server_config server # Install generated configs atomically; fail on any error for cfg in logger.conf relay.conf irc.conf; do install_conf "$tmp_dir/$cfg" "$CONFIG_DIR/$cfg" || { rm -rf "$tmp_dir" trap - EXIT INT TERM return 1 } done rm -rf "$tmp_dir" trap - EXIT INT TERM return 0 } start_service() { local enabled config_load weechat config_get_bool enabled weechat enabled 0 if [ "$enabled" -ne 1 ]; then logger -t weechat -p daemon.info "service is disabled in /etc/config/weechat (option enabled '0')" return 0 fi validate_config || return 1 prepare_dirs || { log_err "failed to prepare weechat directories" return 1 } # Generate native WeeChat configurations from UCI generate_weechat_configs || { log_err "failed to generate weechat configurations" return 1 } procd_open_instance "weechat" procd_set_param command "$PROG" --stdout \ --dir "$CONFIG_DIR:$DATA_DIR:$CACHE_DIR:$RUNTIME_DIR" procd_set_param file /etc/config/weechat procd_set_param user "$USER" procd_set_param group "$GROUP" procd_set_param respawn procd_set_param stdout 1 procd_set_param stderr 1 procd_close_instance } # Stop WeeChat before regenerating its files. It saves its configuration # when it quits, while a plain reload regenerates the files first and only # then lets procd restart the process (which in testing did not even get # to quit cleanly). reload_service() { stop start } service_triggers() { procd_add_reload_trigger "weechat" }