Files
logwisp/deploy/lw-deploy.sh
T
2026-10-11 03:27:10 -04:00

1332 lines
50 KiB
Bash
Executable File

#!/bin/sh
# Deploys one LogWisp node: an edge (log files -> chain), an aggregator (chain
# -> files, streams) or a standalone node (log files -> files, streams), as a
# docker or podman container, a systemd or FreeBSD rc.d service, or a plain
# configuration. Asks for what the flags leave open; -h lists the flags and
# doc/deployment.md walks through the scenarios.
set -u
set -f # globs such as *.log are values here, never file names
umask 022
PROG=${0##*/}
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 1
REPO=${SCRIPT_DIR%/*}
PACKAGE_DIR=$SCRIPT_DIR/package
NL='
'
OIFS=$IFS
IMAGE_USER=65532:65532
PKG_UNIT=/usr/lib/systemd/system/logwisp.service # a package's, used as it is
HOME_DIR=${HOME:-$PWD} # keeps keys and passwords out of a checkout run from its root
ROLE='' RUNTIME='' ENGINE='' TARGET_OS='' NAME='' CONF_DIR='' JAIL=''
LOG_DIRS='' LOG_PATTERN='' FROM='' LOG_GROUP=''
CHAIN='' AGGREGATOR='' CHAIN_PORT='' NODE='' LISTEN=''
FILE_DIR='' FILE_NAME='' HTTP_PORT='' TCP_PORT='' FORMAT=''
TLS='' CERT_FILE='' KEY_FILE='' CA_FILE='' SERVER_NAME=''
AUTH='' ALLOW='' USERNAME='' PASSWORD_FILE='' ADD_USERS=''
SINK_AUTH='' SINK_ALLOW='' ADD_VIEWERS='' SECRETS_DIR=''
IMAGE='' BUILD='' BUILD_CA='' BUILD_FLAGS='' NETWORK='' BIN=''
START='' FORCE='' YES=0 DRY_RUN=0
usage() {
cat <<EOF
Usage: $PROG [options]
Writes a LogWisp configuration for one node and deploys it. Without --yes it
asks for every value the options leave open, with inline menus; with --yes it
takes the defaults and fails on a missing required value.
General
-y, --yes no prompts: take defaults, fail on missing values
-n, --dry-run print the configuration and commands, change nothing
-h, --help this text
--role ROLE edge | aggregator | standalone
--runtime RUNTIME docker | podman | native | manual (configuration only)
--engine ENGINE docker | podman, for --runtime docker (default docker)
--os OS linux | freebsd, the target (default: this host)
--force overwrite an existing configuration, replace a container
--start, --no-start start the container or service (default: start)
Sources (edge, standalone)
--log-dir DIR directory to tail, not recursive; repeatable
--log-pattern GLOB files to tail in each directory (default *.log)
--from end|start where a newly found file is read from (default end)
--log-group GROUP group allowed to read the logs, given to the service
Chain (edge dials, aggregator listens)
--chain tcp|http transport (default tcp)
--aggregator HOST edge: the aggregator's address
--chain-port PORT default 9440 (tcp) or 9441 (http)
--node LABEL edge: origin label (default: short host name)
--listen ADDR listeners' address (default 0.0.0.0; for a container
on a bridge network, the address its ports publish on)
Outputs (aggregator, standalone)
--file-dir DIR write rotating files there
--file-name NAME file name stem (default logwisp)
--http-port PORT serve an SSE stream at /stream and status at /status
--tcp-port PORT serve a plain TCP stream
--format raw|txt|json entry format (default txt)
TLS (this script creates no certificates: lw tls makes them, doc/cli.md#lw-tls)
--tls, --no-tls TLS on every network link of the node
--cert-file FILE this node's certificate: listeners, and edges under mtls
--key-file FILE its private key
--ca-file FILE CA bundle: an edge verifies the aggregator with it, a
listener the client certificates
--server-name NAME edge: name the aggregator's certificate must carry
Authentication (needs TLS; without --auth or --sink-auth the flags below imply
their mode, and under --yes one that does not apply stops the run)
--auth none|mtls|scram the chain link
--allow ID mtls certificate CN; an edge pins the aggregator, an
aggregator admits edges; repeatable (none: any the CA issued)
--username NAME edge, scram (default: the password file's name
without .pass, as the aggregator wrote it)
--password-file FILE edge, scram: the password the aggregator generated
--add-user NAME aggregator, scram: create or update an edge's user; repeatable
--sink-auth none|mtls|scram the http and tcp outputs
--sink-allow ID mtls viewer CN; repeatable
--add-viewer NAME scram viewer; repeatable
--secrets-dir DIR generated passwords, absolute (default ~/logwisp-secrets)
Containers (Linux)
--name NAME container name (default logwisp-ROLE)
--config-dir DIR host directory mounted at /etc/logwisp (default
/etc/logwisp/NAME); --runtime manual writes there
(default ~/logwisp-ROLE)
--image IMAGE default logwisp:dev, what "make image" builds
--build, --no-build build IMAGE from this repository (native: make build)
--build-ca FILE CA of an HTTPS-intercepting proxy, for the build only
--build-flags FLAGS extra build flags, split on spaces ("--network host")
--network NET container network, created when missing; "host"
publishes nothing and listens on --listen
Native services
--bin FILE lw binary to install (default: the installed one, else bin/lw)
--jail NAME FreeBSD: install into this running jail through jexec
--package-dir DIR service files (default deploy/package)
EOF
not_covered ''
}
not_covered() { # PREFIX
sed "s/^/$1/" <<'EOF'
Not covered by this script; edit the generated configuration instead
(config/logwisp.toml documents every key):
- filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy (trusted_proxies)
- acl on the listeners: address rules, PROXY headers, per-client limits
- mtls identities other than the certificate CN; node_binding other than force
- creating certificates: lw tls, openssl or your PKI (doc/cli.md#lw-tls)
- several native instances on one host: use one per host or jail
EOF
}
die() { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; }
warn() { printf '%s: warning: %s\n' "$PROG" "$*" >&2; }
note() { printf '%s\n' "$*"; }
add_item() { if [ -z "$1" ]; then printf '%s' "$2"; else printf '%s\n%s' "$1" "$2"; fi; }
shquote() {
case $1 in
'') printf "''" ;;
*[!A-Za-z0-9_./:=,@%+-]*) printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" ;;
*) printf '%s' "$1" ;;
esac
}
tq() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; }
tlist() { # newline-separated list -> TOML array
_tl=''
IFS=$NL
for _i in $1; do _tl="$_tl${_tl:+, }$(tq "$_i")"; done
IFS=$OIFS
printf '[%s]' "$_tl"
}
urlhost() { case $1 in *:*) printf '[%s]' "$1" ;; *) printf '%s' "$1" ;; esac; }
# in a URL a zone's % is escaped as %25 (RFC 6874)
urihost() { case $1 in *%*) urlhost "${1%%\%*}%25${1#*%}" ;; *) urlhost "$1" ;; esac; }
# --- commands: printed, and run unless --dry-run ---
show_cmd() { # a long command breaks before each option up to its first operand
printf '+'
_brk='' _n=0 _prev=-
[ $# -le 12 ] || _brk=yes
for _a in "$@"; do
_n=$((_n + 1))
_b=$_brk
case $_a in -*) ;; *) case $_prev in -*) _b='' ;; *) [ "$_n" -le 2 ] || _brk='' ;; esac ;; esac
[ "$_b" != yes ] || [ "$_n" -le 2 ] || printf ' \\\n '
printf ' %s' "$(shquote "$_a")"
_prev=$_a
done
}
run() {
show_cmd "$@"
printf '\n'
[ "$DRY_RUN" = 1 ] || "$@"
}
run_in() { # FILE COMMAND...: COMMAND reads FILE on stdin
_in=$1
shift
show_cmd "$@"
printf ' < %s\n' "$(shquote "$_in")"
[ "$DRY_RUN" = 1 ] || "$@" <"$_in"
}
# trun runs inside the target jail when there is one; tprobe also, unprinted.
trun() { if [ -n "$JAIL" ]; then run jexec "$JAIL" "$@"; else run "$@"; fi; }
tprobe() { if [ -n "$JAIL" ]; then jexec "$JAIL" "$@"; else "$@"; fi; }
# put_file writes stdin, or SOURCE, to PATH in the target: a jail's own
# symlinks then resolve inside the jail, never on the host.
put_file() { # PATH MODE [SOURCE]
if [ "$DRY_RUN" = 1 ]; then
if [ -n "${3:-}" ]; then
printf '+ copy %s to %s (mode %s)%s\n' "$(shquote "$3")" "$(shquote "$1")" "$2" "${JAIL:+ in jail $JAIL}"
else
printf '+ write %s (mode %s)%s:\n' "$(shquote "$1")" "$2" "${JAIL:+ in jail $JAIL}"
sed 's/^/ | /'
fi
return 0
fi
[ -z "${3:-}" ] || { put_file "$1" "$2" <"$3"; return; }
# shellcheck disable=SC2016 # expanded by the inner shell
tprobe /bin/sh -c 'umask 077 && t=$1.lw-deploy.$$ &&
{ cat >"$t" && chmod "$2" "$t" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }; }' put_file "$1" "$2" ||
die "cannot write $1${JAIL:+ in jail $JAIL}"
note "wrote $1${JAIL:+ in jail $JAIL}"
}
# --- prompts: a value given as a flag is validated, never asked again ---
prompt_line() { # PROMPT DEFAULT -> REPLY
if [ -n "$2" ]; then printf '%s [%s]: ' "$1" "$2" >&2; else printf '%s: ' "$1" >&2; fi
IFS= read -r REPLY || die "input ended"
}
ask() { # VAR FLAG PROMPT DEFAULT [VALIDATOR]
_var=$1 _flag=$2 _prompt=$3 _def=$4 _check=${5:-}
eval "_val=\$$_var"
if [ -z "$_val" ] && [ "$INTERACTIVE" = 0 ]; then
[ -n "$_def" ] || die "missing value: $_flag"
_val=$_def
fi
while [ -z "$_val" ] || { [ -n "$_check" ] && ! "$_check" "$_val"; }; do
[ "$INTERACTIVE" = 1 ] || die "invalid $_flag: $_val"
prompt_line "$_prompt" "$_def"
_val=${REPLY:-$_def}
done
eval "$_var=\$_val"
}
ask_opt() { # VAR FLAG PROMPT [VALIDATOR]: empty is an answer
_var=$1 _flag=$2 _prompt=$3 _check=${4:-}
eval "_val=\$$_var"
if [ -z "$_val" ] && [ "$INTERACTIVE" = 1 ]; then
prompt_line "$_prompt" ''
_val=$REPLY
fi
while [ -n "$_val" ] && [ -n "$_check" ] && ! "$_check" "$_val"; do
[ "$INTERACTIVE" = 1 ] || die "invalid $_flag: $_val"
prompt_line "$_prompt" ''
_val=$REPLY
done
eval "$_var=\$_val"
}
ask_list() { # VAR FLAG PROMPT VALIDATOR REQUIRED(yes|no); answers split on spaces
_var=$1 _flag=$2 _prompt=$3 _check=$4 _req=$5
eval "_val=\$$_var"
while :; do
if [ -z "$_val" ] && [ "$INTERACTIVE" = 1 ]; then
prompt_line "$_prompt" ''
_val=$(printf '%s\n' "$REPLY" | tr '\t' ' ' | tr -s ' ' '\n' | sed '/^$/d')
fi
if [ -z "$_val" ] && [ "$_req" = yes ]; then
[ "$INTERACTIVE" = 1 ] || die "missing value: $_flag"
continue
fi
_bad=0
IFS=$NL
for _i in $_val; do "$_check" "$_i" || _bad=1; done
IFS=$OIFS
[ "$_bad" = 0 ] && break
[ "$INTERACTIVE" = 1 ] || die "invalid $_flag"
_val=''
done
eval "$_var=\$_val"
}
choose() { # VAR FLAG PROMPT DEFAULT VALUE DESCRIPTION [VALUE DESCRIPTION]...
_var=$1 _flag=$2 _prompt=$3 _def=$4
shift 4
eval "_val=\$$_var"
_vals='' _menu='' _n=0 _defn=''
while [ $# -ge 2 ]; do
_n=$((_n + 1))
_vals=$_vals$1$NL
_menu=$_menu$(printf ' %d) %-11s %s' "$_n" "$1" "$2")$NL
[ "$1" = "$_def" ] && _defn=$_n
shift 2
done
while :; do
if [ -n "$_val" ]; then
case $NL$_vals in *"$NL$_val$NL"*) break ;; esac
[ "$INTERACTIVE" = 1 ] || die "invalid $_flag: $_val (one of: $(printf '%s' "$_vals" | tr '\n' ' '))"
printf ' not one of the choices: %s\n' "$_val" >&2
elif [ "$INTERACTIVE" = 0 ]; then
[ -n "$_def" ] || die "missing value: $_flag"
_val=$_def
continue
fi
printf '%s\n%s' "$_prompt" "$_menu" >&2
prompt_line ' choice' "$_defn"
_val=${REPLY:-$_defn}
case $_val in
[1-9] | [1-9][0-9]) [ "$_val" -le "$_n" ] && _val=$(printf '%s' "$_vals" | sed -n "${_val}p") ;;
esac
done
eval "$_var=\$_val"
}
ask_yn() { # VAR PROMPT DEFAULT(yes|no)
_var=$1 _prompt=$2 _def=$3
eval "_val=\$$_var"
if [ -z "$_val" ] && [ "$INTERACTIVE" = 1 ]; then
while :; do
if [ "$_def" = yes ]; then prompt_line "$_prompt [Y/n]" ''; else prompt_line "$_prompt [y/N]" ''; fi
case $REPLY in
'') _val=$_def ;;
[Yy] | [Yy][Ee][Ss]) _val=yes ;;
[Nn] | [Nn][Oo]) _val=no ;;
*) continue ;;
esac
break
done
fi
eval "$_var=\${_val:-\$_def}"
}
# With NEEDS, an auth flag: --yes stops, as dropping it would leave the node open.
drop() { # VAR FLAG [NEEDS]
eval "_val=\$$1"
[ -z "$_val" ] || [ -z "${3:-}" ] || [ "$INTERACTIVE" = 1 ] || die "$2 needs $3"
[ -z "$_val" ] || warn "$2 does not apply to these choices; ignored"
eval "$1=''"
}
# --- validators: print why and fail; free text rejects control characters, which
# would break out of a TOML string ---
bad() { printf ' %s\n' "$*" >&2; return 1; }
v_port() {
case $1 in '' | *[!0-9]* | 0* | ??????*) ;; *) [ "$1" -le 65535 ] && return 0 ;; esac
bad "not a port (1-65535): $1"
}
v_abs() {
case $1 in *:* | *[[:cntrl:]]*) bad "':' (a mount separator) or a control character in a path: $1" ;; /*) return 0 ;; *) bad "not an absolute path: $1" ;; esac
}
v_ident() { case $1 in '' | *[!A-Za-z0-9._@-]*) bad "use letters, digits and . _ @ -: $1" ;; esac; }
v_cname() { case $1 in [A-Za-z0-9]*) case $1 in *[!A-Za-z0-9_.-]*) ;; *) return 0 ;; esac ;; esac; bad "not a container name: $1"; }
v_host() { case $1 in '' | *[!A-Za-z0-9.:%_-]*) bad "not a host name or address: $1" ;; esac; }
v_glob() { case $1 in */* | *[[:cntrl:]]* | '') bad "a file name pattern, without '/' or control characters: $1" ;; esac; }
v_word() { case $1 in '' | *[!A-Za-z0-9_./:@-]*) bad "unexpected characters: $1" ;; esac; }
v_any() { case $1 in '' | *[[:cntrl:]]*) bad "an empty value or a control character: $1" ;; esac; }
v_file() {
[ -f "$1" ] && [ -r "$1" ] && return 0
[ "$DRY_RUN" = 1 ] && { warn "not a readable file here: $1"; return 0; }
bad "not a readable file: $1"
}
v_dir() { # a directory the service reads or writes
v_abs "$1" || return 1
[ "$RUNTIME/$TARGET_OS" = native/linux ] || return 0
case $1/ in /tmp/* | /var/tmp/*) bad "the systemd unit gives lw its own /tmp and /var/tmp (PrivateTmp=yes): $1" ;; esac
}
v_logdir() {
v_dir "$1" || return 1
[ -d "$ROOT$1" ] && return 0
[ "$DRY_RUN" = 1 ] && { warn "no such directory here: $ROOT$1"; return 0; }
bad "no such directory: $ROOT$1"
}
# --- arguments ---
need_arg() { [ $# -ge 2 ] || die "$1 needs a value"; }
while [ $# -gt 0 ]; do
case $1 in
--*=*)
_o=${1%%=*} _v=${1#*=}
shift
set -- "$_o" "$_v" "$@"
;;
esac
case $1 in
-h | --help) usage; exit 0 ;;
-y | --yes) YES=1 ;;
-n | --dry-run) DRY_RUN=1 ;;
--force) FORCE=yes ;;
--start) START=yes ;;
--no-start) START=no ;;
--tls) TLS=yes ;;
--no-tls) TLS=no ;;
--build) BUILD=yes ;;
--no-build) BUILD=no ;;
--log-dir) need_arg "$@"; LOG_DIRS=$(add_item "$LOG_DIRS" "$2"); shift ;;
--allow) need_arg "$@"; ALLOW=$(add_item "$ALLOW" "$2"); shift ;;
--add-user) need_arg "$@"; ADD_USERS=$(add_item "$ADD_USERS" "$2"); shift ;;
--sink-allow) need_arg "$@"; SINK_ALLOW=$(add_item "$SINK_ALLOW" "$2"); shift ;;
--add-viewer) need_arg "$@"; ADD_VIEWERS=$(add_item "$ADD_VIEWERS" "$2"); shift ;;
--*)
case $1 in
--role) _var=ROLE ;; --runtime) _var=RUNTIME ;; --engine) _var=ENGINE ;;
--os) _var=TARGET_OS ;; --name) _var=NAME ;; --config-dir) _var=CONF_DIR ;;
--jail) _var=JAIL ;; --log-pattern) _var=LOG_PATTERN ;; --from) _var=FROM ;;
--log-group) _var=LOG_GROUP ;; --chain) _var=CHAIN ;; --aggregator) _var=AGGREGATOR ;;
--chain-port) _var=CHAIN_PORT ;; --node) _var=NODE ;; --listen) _var=LISTEN ;;
--file-dir) _var=FILE_DIR ;; --file-name) _var=FILE_NAME ;; --http-port) _var=HTTP_PORT ;;
--tcp-port) _var=TCP_PORT ;; --format) _var=FORMAT ;; --cert-file) _var=CERT_FILE ;;
--key-file) _var=KEY_FILE ;; --ca-file) _var=CA_FILE ;; --server-name) _var=SERVER_NAME ;;
--auth) _var=AUTH ;; --username) _var=USERNAME ;; --password-file) _var=PASSWORD_FILE ;;
--sink-auth) _var=SINK_AUTH ;; --secrets-dir) _var=SECRETS_DIR ;; --image) _var=IMAGE ;;
--build-ca) _var=BUILD_CA ;; --build-flags) _var=BUILD_FLAGS ;; --network) _var=NETWORK ;;
--bin) _var=BIN ;; --package-dir) _var=PACKAGE_DIR ;;
*) die "unknown option: $1 (see $PROG -h)" ;;
esac
need_arg "$@"
[ -n "$2" ] || die "$1 needs a value"
eval "$_var=\$2"
shift
;;
*) die "unexpected argument: $1 (see $PROG -h)" ;;
esac
shift
done
if [ "$YES" = 1 ]; then
INTERACTIVE=0
elif [ -t 0 ]; then
INTERACTIVE=1
elif [ "$DRY_RUN" = 1 ]; then
INTERACTIVE=0
else
die "stdin is not a terminal: pass --yes and the values as flags"
fi
# --- questions ---
host_os() {
case $(uname -s) in
Linux) echo linux ;;
FreeBSD) echo freebsd ;;
*) echo unknown ;;
esac
}
short_host() { _h=$(uname -n); printf '%s' "${_h%%.*}"; }
freebsd_no_containers() {
cat >&2 <<'EOF'
FreeBSD has no Docker. Podman on FreeBSD runs FreeBSD OCI images only, and the
LogWisp image is a Linux image, so containers are not supported there. Run
LogWisp natively instead: on the host, or inside a jail with --jail NAME.
EOF
}
jail_guide() {
cat >&2 <<EOF
Jail "$JAIL" is not running here. Create and start it first, for example in
/etc/jail.conf.d/$JAIL.conf:
$JAIL {
path = "/usr/local/jails/$JAIL";
host.hostname = "$JAIL";
ip4.addr = "lo1|10.0.0.10/32"; # or an address on a real interface
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
mount.devfs;
}
then extract a base system (base.txz) into its path and start it:
sysrc jail_enable=YES && service jail start $JAIL
A one-off jail without a file: jail -c name=$JAIL path=... host.hostname=$JAIL \\
ip4.addr=... exec.start="/bin/sh /etc/rc" persist
EOF
}
resolve() {
choose ROLE --role "Role of this node:" edge \
edge "tail log files, forward them to an aggregator" \
aggregator "receive from edges; write files, serve streams" \
standalone "tail log files; write files, serve streams"
HOST_OS=$(host_os)
[ -n "$TARGET_OS" ] || TARGET_OS=$HOST_OS
case $TARGET_OS in linux | freebsd) ;; *) die "unsupported target OS: $TARGET_OS (--os linux|freebsd)" ;; esac
_dv=docker
[ "$TARGET_OS" = freebsd ] && _dv=native
[ "$RUNTIME" = podman ] && RUNTIME=docker ENGINE=${ENGINE:-podman}
choose RUNTIME --runtime "Run it as:" "$_dv" \
docker "docker container (Linux)" \
podman "podman container (Linux)" \
native "service: systemd on Linux, rc.d on FreeBSD or in a jail" \
manual "write the configuration only; run lw yourself"
[ "$RUNTIME" = podman ] && RUNTIME=docker ENGINE=podman
if [ "$RUNTIME" = docker ] && [ "$TARGET_OS" = freebsd ]; then
freebsd_no_containers
[ "$INTERACTIVE" = 1 ] || die "use --runtime native (add --jail NAME for a jail)"
_sw=''
ask_yn _sw "Install natively instead?" yes
[ "$_sw" = yes ] || exit 1
RUNTIME=native
fi
if [ "$RUNTIME" = native ] && [ "$TARGET_OS" != "$HOST_OS" ] && [ "$DRY_RUN" = 0 ]; then
die "--os $TARGET_OS with --runtime native needs a $TARGET_OS host (or --dry-run, or --runtime manual)"
fi
if [ "$RUNTIME/$TARGET_OS" = native/linux ] && [ -f "$PKG_UNIT" ] && { [ -n "$BIN" ] || [ "$BUILD" = yes ]; }; then
die "a package provides lw ($PKG_UNIT); drop --bin/--build"
fi
[ -n "$ENGINE" ] || ENGINE=docker
case $ENGINE in docker | podman) ;; *) die "invalid --engine: $ENGINE (docker|podman)" ;; esac
[ "$RUNTIME" = native ] && [ -n "$CONF_DIR" ] && die "a native install keeps its configuration in SYSCONFDIR/logwisp; --config-dir is for containers and manual"
if [ -n "$JAIL" ] && [ "$RUNTIME/$TARGET_OS" != native/freebsd ]; then
die "--jail needs --runtime native on FreeBSD"
fi
ROOT=''
case $RUNTIME in
docker)
ask NAME --name "Container name" "logwisp-$ROLE" v_cname
ask CONF_DIR --config-dir "Host directory for the configuration (mounted at /etc/logwisp)" "/etc/logwisp/$NAME" v_abs
CONF_HOST=$CONF_DIR CONF_LW=/etc/logwisp
ask IMAGE --image "Image" logwisp:dev v_word
;;
native)
if [ "$TARGET_OS" = freebsd ]; then
ask_opt JAIL --jail "Jail to install into (empty: this host)" v_cname
if [ -n "$JAIL" ]; then
ROOT=$(jls -j "$JAIL" path 2>/dev/null) || ROOT=''
if [ -z "$ROOT" ]; then
jail_guide
[ "$DRY_RUN" = 1 ] || exit 1
ROOT=/usr/local/jails/$JAIL
warn "showing paths under $ROOT"
fi
fi
CONF_LW=/usr/local/etc/logwisp
else
CONF_LW=/etc/logwisp
fi
CONF_HOST=$ROOT$CONF_LW
;;
manual)
ask CONF_DIR --config-dir "Directory for the configuration" "$HOME_DIR/logwisp-$ROLE" v_abs
CONF_HOST=$CONF_DIR CONF_LW=$CONF_DIR
;;
esac
if [ "$ROLE" != aggregator ]; then
if [ -z "$LOG_DIRS" ] && [ "$INTERACTIVE" = 1 ]; then
note "Log directories to tail, one per line; an empty line ends the list." >&2
while :; do
prompt_line " directory" ''
[ -n "$REPLY" ] || { [ -n "$LOG_DIRS" ] && break; continue; }
v_logdir "$REPLY" && LOG_DIRS=$(add_item "$LOG_DIRS" "$REPLY")
done
fi
ask_list LOG_DIRS --log-dir "Log directories, space-separated" v_logdir yes
ask LOG_PATTERN --log-pattern "File name pattern in each directory" '*.log' v_glob
FROM=${FROM:-end}
case $FROM in start | end) ;; *) die "invalid --from: $FROM (start|end)" ;; esac
[ "$RUNTIME" = manual ] || ask_opt LOG_GROUP --log-group "Group that may read these logs, for the service (empty: none)" v_ident
fi
if [ "$ROLE" != standalone ]; then
choose CHAIN --chain "Chain transport:" tcp \
tcp "tcp_chain: one long-lived connection" \
http "http_chain: batched HTTP POSTs"
_dv=9440
[ "$CHAIN" = http ] && _dv=9441
if [ "$ROLE" = edge ]; then
ask AGGREGATOR --aggregator "Aggregator host or address" '' v_host
ask CHAIN_PORT --chain-port "Aggregator chain port" "$_dv" v_port
_dv=$(short_host)
[ -z "$JAIL" ] || _dv=$JAIL
ask NODE --node "Node label for this edge's entries" "$_dv" v_ident
else
ask CHAIN_PORT --chain-port "Chain listener port" "$_dv" v_port
fi
fi
if [ "$ROLE" != edge ]; then
ask LISTEN --listen "Listen address" 0.0.0.0 v_host
if [ -z "$FILE_DIR$HTTP_PORT$TCP_PORT" ]; then
[ "$INTERACTIVE" = 1 ] || die "no output: give --file-dir, --http-port or --tcp-port"
_want='' _dv=/var/log/logwisp
[ "$RUNTIME" = docker ] && _dv=/var/log/$NAME
[ "$RUNTIME" = manual ] && _dv=$CONF_DIR/out
ask_yn _want "Write the entries to rotating files?" yes
[ "$_want" = no ] || ask FILE_DIR --file-dir "Output directory" "$_dv" v_dir
_want=''
ask_yn _want "Serve a live HTTP stream (SSE) with a status endpoint?" no
[ "$_want" = no ] || ask HTTP_PORT --http-port "HTTP port" 8080 v_port
_want=''
ask_yn _want "Serve a plain TCP stream?" no
[ "$_want" = no ] || ask TCP_PORT --tcp-port "TCP port" 9090 v_port
[ -n "$FILE_DIR$HTTP_PORT$TCP_PORT" ] || die "no output chosen"
fi
[ -z "$FILE_DIR" ] || ask FILE_DIR --file-dir "Output directory" '' v_dir
[ -z "$FILE_DIR" ] || ask FILE_NAME --file-name "Output file name stem" logwisp v_ident
[ -z "$HTTP_PORT" ] || ask HTTP_PORT --http-port "HTTP port" '' v_port
[ -z "$TCP_PORT" ] || ask TCP_PORT --tcp-port "TCP port" '' v_port
choose FORMAT --format "Entry format:" txt \
txt "timestamp, level, node/source, message" \
json "one JSON object per entry" \
raw "the message only"
_dup=$(printf '%s\n' "$CHAIN_PORT" "$HTTP_PORT" "$TCP_PORT" | sed '/^$/d' | sort | uniq -d)
[ -z "$_dup" ] || die "two listeners on port $_dup"
fi
# A standalone node without network outputs has no link to secure.
if [ "$ROLE" = standalone ] && [ -z "$HTTP_PORT$TCP_PORT" ]; then
[ "$TLS" != yes ] || warn "ignoring --tls: no http or tcp output"
TLS=no
fi
_dv=no
[ -z "$CERT_FILE$CA_FILE" ] || _dv=yes
ask_yn TLS "Use TLS on this node's network links?" "$_dv"
# Auth-only flags imply their mode, the default --yes takes.
_ia=none _isa=none
[ -z "$ALLOW" ] || _ia=mtls
[ -z "$ADD_USERS$USERNAME$PASSWORD_FILE" ] || _ia=scram
[ -z "$SINK_ALLOW" ] || _isa=mtls
[ -z "$ADD_VIEWERS" ] || _isa=scram
if [ "$TLS" = yes ]; then
[ "$INTERACTIVE" = 0 ] || note "Certificates come from lw tls, openssl or your PKI: doc/cli.md#lw-tls" >&2
if [ "$ROLE" = edge ]; then
ask_opt CA_FILE --ca-file "CA bundle verifying the aggregator (empty: the system roots)" v_file
else
ask CERT_FILE --cert-file "This node's certificate (PEM)" '' v_file
ask KEY_FILE --key-file "Its private key (PEM)" '' v_file
fi
[ "$ROLE" = standalone ] || choose AUTH --auth "Authenticate the chain link:" "$_ia" \
none "TLS only" \
mtls "client certificates, admitted by CN" \
scram "username and password"
else
[ -z "$CERT_FILE$KEY_FILE$CA_FILE$SERVER_NAME" ] || die "certificate options need --tls"
[ -z "$AUTH" ] || [ "$AUTH" = none ] || die "--auth $AUTH needs --tls"
[ -z "$SINK_AUTH" ] || [ "$SINK_AUTH" = none ] || die "--sink-auth $SINK_AUTH needs --tls"
fi
[ "$ROLE" != standalone ] || drop AUTH --auth
AUTH=${AUTH:-none}
case $ROLE/$AUTH in
edge/mtls)
ask CERT_FILE --cert-file "This edge's client certificate (PEM)" '' v_file
ask KEY_FILE --key-file "Its private key (PEM)" '' v_file
ask_list ALLOW --allow "Aggregator certificate CN to pin (empty: any the CA issued)" v_any no
;;
edge/scram)
ask PASSWORD_FILE --password-file "Password file (from the aggregator's secrets directory)" '' v_file
# The aggregator names each generated password file after its user.
_dv=${PASSWORD_FILE##*/}
_dv=${_dv%.pass}
case $_dv in '' | *[!A-Za-z0-9._@-]*) _dv=$NODE ;; esac
ask USERNAME --username "SCRAM username" "$_dv" v_ident
;;
aggregator/mtls)
ask CA_FILE --ca-file "CA bundle verifying the edges' certificates" '' v_file
ask_list ALLOW --allow "Edge certificate CNs to admit, space-separated (empty: any the CA issued)" v_any no
;;
aggregator/scram)
_req=yes
[ -s "$CONF_HOST/users.toml" ] && _req=no
ask_list ADD_USERS --add-user "Edge usernames to create or update, space-separated" v_ident "$_req"
;;
esac
_c=${CERT_FILE:+1} _k=${KEY_FILE:+1}
[ "$_c" = "$_k" ] || die "--cert-file and --key-file go together"
_viewers=no
[ "$ROLE" != edge ] && [ -n "$HTTP_PORT$TCP_PORT" ] && [ "$TLS" = yes ] && _viewers=yes
if [ "$_viewers" = yes ]; then
choose SINK_AUTH --sink-auth "Authenticate the stream viewers (http/tcp outputs):" "$_isa" \
none "TLS only" \
mtls "client certificates, admitted by CN" \
scram "username and password (lw auth token / stream)"
case $SINK_AUTH in
mtls)
ask CA_FILE --ca-file "CA bundle verifying the viewers' certificates" '' v_file
ask_list SINK_ALLOW --sink-allow "Viewer certificate CNs to admit (empty: any the CA issued)" v_any no
;;
scram)
_req=yes
[ -s "$CONF_HOST/viewers.toml" ] && _req=no
ask_list ADD_VIEWERS --add-viewer "Viewer usernames to create or update, space-separated" v_ident "$_req"
;;
esac
else
SINK_AUTH=none
fi
# Values the choices made moot are dropped, so the plan shows what applies.
_n='--auth scram (TLS, on an edge)'
[ "$ROLE/$AUTH" = edge/mtls ] || [ "$ROLE/$AUTH" = aggregator/mtls ] ||
drop ALLOW --allow '--auth mtls (TLS, on an edge or aggregator)'
[ "$ROLE/$AUTH" = edge/scram ] || { drop USERNAME --username "$_n"; drop PASSWORD_FILE --password-file "$_n"; }
[ "$ROLE/$AUTH" = aggregator/scram ] || drop ADD_USERS --add-user '--auth scram (TLS, on an aggregator)'
[ "$SINK_AUTH" = mtls ] || drop SINK_ALLOW --sink-allow '--sink-auth mtls (TLS, an http or tcp output)'
[ "$SINK_AUTH" = scram ] || drop ADD_VIEWERS --add-viewer '--sink-auth scram (TLS, an http or tcp output)'
[ "$ROLE" = edge ] || { drop SERVER_NAME --server-name; drop AGGREGATOR --aggregator; drop NODE --node; }
[ "$ROLE" != edge ] || { drop FILE_DIR --file-dir; drop HTTP_PORT --http-port; drop TCP_PORT --tcp-port; }
[ "$ROLE" != aggregator ] || drop LOG_DIRS --log-dir
[ -z "$SERVER_NAME" ] || v_host "$SERVER_NAME" || exit 1
if [ -n "$ADD_USERS$ADD_VIEWERS" ]; then
ask SECRETS_DIR --secrets-dir "Directory for the generated passwords" "$HOME_DIR/logwisp-secrets" v_abs
else
drop SECRETS_DIR --secrets-dir
fi
if [ "$RUNTIME" = docker ]; then
if [ -z "$BUILD" ]; then
if "$ENGINE" image inspect "$IMAGE" >/dev/null 2>&1; then
BUILD=no
elif [ "$INTERACTIVE" = 1 ]; then
ask_yn BUILD "Image $IMAGE is not here; build it from $REPO?" yes
else
BUILD=no
warn "image $IMAGE is not here; $ENGINE will try to pull it (--build builds it)"
fi
fi
[ -z "$BUILD_CA" ] || v_file "$BUILD_CA" || exit 1
[ -z "$NETWORK" ] || v_word "$NETWORK" || exit 1
if [ "$NETWORK" != host ] && [ "$ROLE" != edge ]; then
case $LISTEN in *:*) ;; *[!0-9.]*) die "--listen $LISTEN: a container publishes its ports on an IP address, not a host name" ;; esac
fi
fi
[ -z "$BIN" ] || v_file "$BIN" || exit 1
for _var in CERT_FILE KEY_FILE CA_FILE PASSWORD_FILE BUILD_CA BIN; do
eval "_val=\$$_var"
case $_val in '' | /*) ;; *) eval "$_var=\$PWD/\$_val" ;; esac
done
BUILD=${BUILD:-no}
[ "$RUNTIME" = manual ] && START=no
ask_yn START "Start it now?" yes
}
# --- derived values ---
derive() {
C_CERT=$CONF_LW/tls/node.crt C_KEY=$CONF_LW/tls/node.key C_CA=$CONF_LW/tls/ca.crt
C_PASS=$CONF_LW/chain.pass C_USERS=$CONF_LW/users.toml C_VIEWERS=$CONF_LW/viewers.toml
CONF_T=${CONF_HOST#"$ROOT"} # CONF_HOST as seen from inside the target
LISTEN=${LISTEN:-0.0.0.0}
BIND=$LISTEN
[ "$RUNTIME" = docker ] && [ "$NETWORK" != host ] && BIND=0.0.0.0
FILE_MODE=0640 SECRET_MODE=0600
[ "$RUNTIME" = native ] && SECRET_MODE=0640
CHOWN_IMAGE=0 USERNS=''
if [ "$RUNTIME" = docker ]; then
if [ "$(id -u)" = 0 ]; then
CHOWN_IMAGE=1
elif [ "$ENGINE" = podman ]; then
USERNS=keep-id:uid=65532,gid=65532
elif [ "$DRY_RUN" = 0 ]; then
die "run as root: the configuration must belong to the image user $IMAGE_USER"
fi
fi
LOG_GID=''
if [ "$RUNTIME" = docker ] && [ -n "$LOG_GROUP" ]; then
LOG_GID=$(getent group "$LOG_GROUP" 2>/dev/null | cut -d: -f3)
[ -n "$LOG_GID" ] || die "no such group here: $LOG_GROUP"
fi
case $RUNTIME in
docker) DESC="$ENGINE container $NAME" ;;
native) DESC="systemd service logwisp" ;;
manual) DESC="configuration for lw -c" ;;
esac
[ "$RUNTIME/$TARGET_OS" = native/freebsd ] && DESC="rc.d service logwisp${JAIL:+ in jail $JAIL}"
OLD_CONF='' OLD_CTR=''
[ ! -f "$CONF_HOST/logwisp.toml" ] || OLD_CONF=$CONF_HOST/logwisp.toml
if [ "$RUNTIME/$START" = docker/yes ] && "$ENGINE" container inspect "$NAME" >/dev/null 2>&1; then
OLD_CTR=$NAME
fi
}
# --- the configuration ---
tls_listener() { # PREFIX AUTH
[ "$TLS" = yes ] || return 0
printf '[%s.tls]\nenabled = true\ncert_file = %s\nkey_file = %s\n' "$1" "$(tq "$C_CERT")" "$(tq "$C_KEY")"
[ "$2" != mtls ] || printf 'client_auth = true\nclient_ca_file = %s\n' "$(tq "$C_CA")"
}
auth_listener() { # PREFIX AUTH ALLOW CREDENTIALS
case $2 in
mtls) printf '[%s.auth]\ntype = "mtls"\nallow = %s\n' "$1" "$(tlist "$3")" ;;
scram) printf '[%s.auth]\ntype = "scram"\ncredentials_file = %s\n' "$1" "$(tq "$4")" ;;
esac
}
gen_config() {
printf '# LogWisp %s node (%s), written by deploy/lw-deploy.sh.\n#\n' "$ROLE" "$DESC"
not_covered '# '
printf '\n[logging]\noutput = "stdout"\nlevel = "info"\n\n[[pipelines]]\nname = "%s"\n' "$ROLE"
[ "$ROLE" = edge ] || printf '\n[pipelines.flow.format]\ntype = "%s"\n' "$FORMAT"
_n=0
IFS=$NL
for _d in $LOG_DIRS; do
IFS=$OIFS
_n=$((_n + 1))
_id=logs
[ "$_n" = 1 ] || _id=logs_$_n
printf '\n[[pipelines.plugin_sources]]\nid = "%s"\ntype = "file"\n[pipelines.plugin_sources.config]\n' "$_id"
printf 'directory = %s\npattern = %s\nfrom = "%s"\n' "$(tq "$_d")" "$(tq "$LOG_PATTERN")" "$FROM"
done
IFS=$OIFS
_p=pipelines.plugin_sources.config
if [ "$ROLE" = aggregator ]; then
printf '\n[[pipelines.plugin_sources]]\nid = "from_edges"\ntype = "%s_chain"\n[%s]\n' "$CHAIN" "$_p"
printf 'host = %s\nport = %s\n' "$(tq "$BIND")" "$CHAIN_PORT"
tls_listener "$_p" "$AUTH"
auth_listener "$_p" "$AUTH" "$ALLOW" "$C_USERS"
fi
_p=pipelines.plugin_sinks.config
if [ "$ROLE" = edge ]; then
printf '\n[[pipelines.plugin_sinks]]\nid = "to_aggregator"\ntype = "%s_chain"\n[%s]\n' "$CHAIN" "$_p"
printf 'host = %s\nport = %s\nnode = %s\n' "$(tq "$AGGREGATOR")" "$CHAIN_PORT" "$(tq "$NODE")"
if [ "$TLS" = yes ]; then
printf '[%s.tls]\nenabled = true\n' "$_p"
[ -z "$CA_FILE" ] || printf 'ca_file = %s\n' "$(tq "$C_CA")"
[ -z "$SERVER_NAME" ] || printf 'server_name = %s\n' "$(tq "$SERVER_NAME")"
[ -z "$CERT_FILE" ] || printf 'cert_file = %s\nkey_file = %s\n' "$(tq "$C_CERT")" "$(tq "$C_KEY")"
fi
case $AUTH in
mtls) printf '[%s.auth]\ntype = "mtls"\nallow = %s\n' "$_p" "$(tlist "$ALLOW")" ;;
scram) printf '[%s.auth]\ntype = "scram"\nusername = %s\npassword_file = %s\n' "$_p" "$(tq "$USERNAME")" "$(tq "$C_PASS")" ;;
esac
return 0
fi
if [ -n "$FILE_DIR" ]; then
printf '\n[[pipelines.plugin_sinks]]\nid = "files"\ntype = "file"\n[%s]\n' "$_p"
printf 'directory = %s\nname = %s\n' "$(tq "$FILE_DIR")" "$(tq "$FILE_NAME")"
fi
for _t in http tcp; do
_port=$HTTP_PORT
[ "$_t" = tcp ] && _port=$TCP_PORT
[ -n "$_port" ] || continue
printf '\n[[pipelines.plugin_sinks]]\nid = "%s"\ntype = "%s"\n[%s]\n' "$_t" "$_t" "$_p"
printf 'host = %s\nport = %s\n' "$(tq "$BIND")" "$_port"
tls_listener "$_p" "$SINK_AUTH"
auth_listener "$_p" "$SINK_AUTH" "$SINK_ALLOW" "$C_VIEWERS"
done
}
# --- the plan, and the command that repeats it ---
EQ=''
eq() {
EQ="$EQ \\$NL $1"
[ $# -lt 2 ] || EQ="$EQ $(shquote "$2")"
}
eq_opt() { [ -z "$2" ] || eq "$1" "$2"; }
eq_list() {
IFS=$NL
for _e in $2; do eq "$1" "$_e"; done
IFS=$OIFS
}
equivalent() {
eq --role "$ROLE"
if [ "$RUNTIME" = docker ] && [ "$ENGINE" = podman ]; then eq --runtime podman; else eq --runtime "$RUNTIME"; fi
[ "$TARGET_OS" = "$HOST_OS" ] || eq --os "$TARGET_OS"
eq_opt --jail "$JAIL"
if [ "$RUNTIME" = docker ]; then
eq --name "$NAME"
eq --image "$IMAGE"
[ "$BUILD" = no ] || eq --build
eq_opt --build-ca "$BUILD_CA"
eq_opt --build-flags "$BUILD_FLAGS"
eq_opt --network "$NETWORK"
fi
[ "$RUNTIME" = native ] || eq --config-dir "$CONF_DIR"
[ "$RUNTIME" != native ] || [ "$BUILD" = no ] || eq --build
eq_opt --bin "$BIN"
eq_list --log-dir "$LOG_DIRS"
if [ "$ROLE" != aggregator ]; then
eq --log-pattern "$LOG_PATTERN"
eq --from "$FROM"
fi
eq_opt --log-group "$LOG_GROUP"
if [ "$ROLE" != standalone ]; then
eq --chain "$CHAIN"
eq_opt --aggregator "$AGGREGATOR"
eq --chain-port "$CHAIN_PORT"
eq_opt --node "$NODE"
fi
if [ "$ROLE" != edge ]; then
eq --listen "$LISTEN"
eq_opt --file-dir "$FILE_DIR"
[ -z "$FILE_DIR" ] || eq --file-name "$FILE_NAME"
eq_opt --http-port "$HTTP_PORT"
eq_opt --tcp-port "$TCP_PORT"
eq --format "$FORMAT"
fi
if [ "$TLS" = yes ]; then eq --tls; else eq --no-tls; fi
eq_opt --cert-file "$CERT_FILE"
eq_opt --key-file "$KEY_FILE"
eq_opt --ca-file "$CA_FILE"
eq_opt --server-name "$SERVER_NAME"
[ "$ROLE" = standalone ] || eq --auth "$AUTH"
eq_list --allow "$ALLOW"
eq_opt --username "$USERNAME"
eq_opt --password-file "$PASSWORD_FILE"
eq_list --add-user "$ADD_USERS"
[ "$ROLE" = edge ] || [ "$TLS" = no ] || [ -z "$HTTP_PORT$TCP_PORT" ] || eq --sink-auth "$SINK_AUTH"
eq_list --sink-allow "$SINK_ALLOW"
eq_list --add-viewer "$ADD_VIEWERS"
eq_opt --secrets-dir "$SECRETS_DIR"
[ "$RUNTIME" = manual ] || { if [ "$START" = yes ]; then eq --start; else eq --no-start; fi; }
printf '%s%s \\\n --yes\n' "$(shquote "$0")" "$EQ"
}
plan() {
note "LogWisp $ROLE node: $DESC"
note " configuration $CONF_HOST/logwisp.toml"
[ "$CONF_HOST" = "$CONF_LW" ] || note " (lw reads it as $CONF_LW/logwisp.toml)"
[ -z "$OLD_CONF" ] || note " replacing the existing one, kept as logwisp.toml.bak"
[ -z "$OLD_CTR" ] || note " replaces the existing container $OLD_CTR"
IFS=$NL
for _d in $LOG_DIRS; do note " tails $_d/$LOG_PATTERN"; done
IFS=$OIFS
_sec=plain
[ "$TLS" = yes ] && _sec="TLS, auth $AUTH"
[ "$ROLE" = edge ] && note " forwards to $(urlhost "$AGGREGATOR"):$CHAIN_PORT over ${CHAIN}_chain ($_sec) as node $NODE"
[ "$ROLE" = aggregator ] && note " receives on $(urlhost "$LISTEN"):$CHAIN_PORT over ${CHAIN}_chain ($_sec)"
_sec=plain
[ "$TLS" = yes ] && _sec="TLS, auth $SINK_AUTH"
[ -z "$FILE_DIR" ] || note " writes $FILE_DIR/$FILE_NAME.log ($FORMAT)"
[ -z "$HTTP_PORT" ] || note " serves http $(urlhost "$LISTEN"):$HTTP_PORT /stream /status ($FORMAT, $_sec)"
[ -z "$TCP_PORT" ] || note " serves tcp $(urlhost "$LISTEN"):$TCP_PORT ($FORMAT, $_sec)"
[ -z "$ADD_USERS$ADD_VIEWERS" ] || note " passwords $SECRETS_DIR/USER.pass"
note ""
note "The same deployment without prompts:"
equivalent
note ""
not_covered ' '
note ""
}
# --- files ---
OWNED=''
own() { OWNED=$(add_item "$OWNED" "$1"); }
install_copy() { # SRC REL MODE: a copy inside the configuration directory
own "$2"
[ "$1" = "$CONF_HOST/$2" ] || put_file "$CONF_T/$2" "$3" "$1"
}
apply_owner() {
_base=$CONF_T
set --
IFS=$NL
for _r in $OWNED; do set -- "$@" "$_base/$_r"; done
IFS=$OIFS
case $RUNTIME in
docker) [ "$CHOWN_IMAGE" = 0 ] || run chown "$IMAGE_USER" "$_base" "$@" || die "chown failed" ;;
native) trun chown root:logwisp "$_base" "$@" || die "chown failed" ;;
esac
}
output_dir() {
[ -n "$FILE_DIR" ] || return 0
case $RUNTIME in
docker) _owner=$IMAGE_USER ;;
native) _owner=logwisp:logwisp ;;
*) _owner='' ;;
esac
if [ -d "$ROOT$FILE_DIR" ]; then
# An existing directory keeps its owner: it may be shared.
case $RUNTIME in
docker) [ "$CHOWN_IMAGE" = 0 ] || [ -n "$(find "$FILE_DIR" -prune -user "${IMAGE_USER%:*}")" ] ||
warn "$FILE_DIR must be writable by $IMAGE_USER: chown $IMAGE_USER $FILE_DIR" ;;
native) [ "$DRY_RUN" = 1 ] || [ -n "$(tprobe find "$FILE_DIR" -prune -user logwisp)" ] ||
warn "$FILE_DIR must be writable by logwisp: chown logwisp:logwisp $FILE_DIR" ;;
esac
return 0
fi
trun install -d -m 0750 "$FILE_DIR" || die "cannot create $FILE_DIR"
case $RUNTIME in
docker) [ "$CHOWN_IMAGE" = 0 ] || run chown "$_owner" "$FILE_DIR" ;;
native) trun chown "$_owner" "$FILE_DIR" ;;
esac
}
write_files() {
if [ -d "$CONF_HOST" ] && [ ! -f "$CONF_HOST/logwisp.toml" ] && [ -n "$(ls -A "$CONF_HOST" 2>/dev/null)" ] && [ "$RUNTIME" != native ]; then
die "$CONF_HOST holds other files; choose an empty or a LogWisp --config-dir"
fi
trun install -d -m 0750 "$CONF_T" || die "cannot create $CONF_HOST"
if [ -n "$CERT_FILE$CA_FILE" ]; then
own tls
trun install -d -m 0750 "$CONF_T/tls" || die "cannot create $CONF_HOST/tls"
fi
[ -z "$CERT_FILE" ] || install_copy "$CERT_FILE" tls/node.crt "$FILE_MODE"
[ -z "$KEY_FILE" ] || install_copy "$KEY_FILE" tls/node.key "$SECRET_MODE"
[ -z "$CA_FILE" ] || install_copy "$CA_FILE" tls/ca.crt "$FILE_MODE"
[ -z "$PASSWORD_FILE" ] || install_copy "$PASSWORD_FILE" chain.pass "$SECRET_MODE"
_creds=''
[ "$ROLE/$AUTH" != aggregator/scram ] || _creds=users.toml
[ "$SINK_AUTH" != scram ] || _creds="$_creds viewers.toml"
for _f in $_creds; do
own "$_f"
# lw auth keeps an existing file's owner and mode.
[ -f "$CONF_HOST/$_f" ] || put_file "$CONF_T/$_f" "$SECRET_MODE" </dev/null
done
if [ -f "$CONF_HOST/logwisp.toml" ] && [ "$DRY_RUN" = 0 ]; then
trun cp -p "$CONF_T/logwisp.toml" "$CONF_T/logwisp.toml.bak" || die "cannot keep a backup"
fi
own logwisp.toml
_text=$(gen_config)
put_file "$CONF_T/logwisp.toml" "$FILE_MODE" <<EOF
$_text
EOF
apply_owner
output_dir
}
gen_password() { # FILE: kept when it exists, so a rerun keeps the password
[ -s "$1" ] && return 0
printf '+ generate a password into %s\n' "$(shquote "$1")"
[ "$DRY_RUN" = 1 ] && return 0
(umask 077 && od -An -tx1 -N16 /dev/urandom | tr -d ' \n' >"$1" && echo >>"$1") || die "cannot write $1"
}
keep_passwords() { # CREDENTIALS(rel) NAMES: a new password would lock out an existing user
IFS=$NL
for _u in $2; do
[ -s "$SECRETS_DIR/$_u.pass" ] || ! grep -qxF "username = \"$_u\"" "$CONF_HOST/$1" 2>/dev/null ||
die "user $_u exists in $1, but $SECRETS_DIR/$_u.pass does not: give the --secrets-dir holding it, or remove the user (lw auth remove-user)"
done
IFS=$OIFS
}
add_users() { # CREDENTIALS(rel) NAMES
[ -n "$2" ] || return 0
_cred=$1
IFS=$NL
for _u in $2; do
IFS=$OIFS
_pw=$SECRETS_DIR/$_u.pass
gen_password "$_pw"
case $RUNTIME in
docker)
set -- "$ENGINE" run --rm -i --network none --read-only --cap-drop ALL \
--security-opt no-new-privileges -u "$IMAGE_USER"
[ -z "$USERNS" ] || set -- "$@" --userns "$USERNS"
set -- "$@" -v "$CONF_HOST:/etc/logwisp" "$IMAGE"
;;
native)
set -- "$BINDIR/lw"
[ -z "$JAIL" ] || set -- jexec "$JAIL" "$@"
;;
manual) set -- "$LW" ;;
esac
run_in "$_pw" "$@" auth add-user -credentials "$CONF_LW/$_cred" -user "$_u" -password-file /dev/stdin ||
die "adding user $_u failed"
done
IFS=$OIFS
}
# --- runtimes ---
pkg_text() { # FILE SED-SCRIPT
if [ -f "$PACKAGE_DIR/$1" ]; then
sed "$2" "$PACKAGE_DIR/$1"
elif [ "$DRY_RUN" = 1 ]; then
warn "missing $PACKAGE_DIR/$1"
printf '(%s/%s, with %s applied)\n' "$PACKAGE_DIR" "$1" "$2"
else
die "missing $PACKAGE_DIR/$1 (--package-dir names another copy)"
fi
}
pick_bin() { # CANDIDATE-DIRS: sets BINDIR, and BIN_SRC when lw must be installed
BIN_SRC=$BIN BINDIR=/usr/local/bin
[ -z "$BIN_SRC" ] || return 0
for _d in $1; do
[ -x "$ROOT$_d/lw" ] && { BINDIR=$_d; return 0; }
done
BIN_SRC=$REPO/bin/lw
[ -x "$BIN_SRC" ] || [ "$DRY_RUN" = 1 ] || die "no lw binary: build it (make build, or --build) or pass --bin FILE"
}
linux_prepare() {
command -v systemctl >/dev/null 2>&1 || [ "$DRY_RUN" = 1 ] || die "a native Linux install needs systemd; use --runtime docker or manual"
if [ -f "$PKG_UNIT" ]; then
# lw auth and lw --check run the binary the packaged unit starts.
BINDIR=$(sed -n 's|^ExecStart=\(/[^ ]*\)/lw\( .*\)\{0,1\}$|\1|p' "$PKG_UNIT")
[ -n "$BINDIR" ] || die "no lw in the ExecStart of $PKG_UNIT"
note "using the packaged logwisp.service and $BINDIR/lw"
else
pick_bin '/usr/local/bin /usr/bin'
[ -z "$BIN_SRC" ] || run install -m 0755 "$BIN_SRC" "$BINDIR/lw" || die "cannot install lw"
_unit=$(pkg_text logwisp.service "s|@BINDIR@|$BINDIR|g; s|@SYSCONFDIR@|/etc|g") || exit 1
put_file /etc/systemd/system/logwisp.service 0644 <<EOF
$_unit
EOF
run install -d -m 0755 /etc/sysusers.d /etc/tmpfiles.d || exit 1
for _f in sysusers tmpfiles; do
_text=$(pkg_text "logwisp.$_f" '') || exit 1
put_file "/etc/$_f.d/logwisp.conf" 0644 <<EOF
$_text
EOF
done
run systemd-sysusers /etc/sysusers.d/logwisp.conf || die "cannot create the logwisp user"
run systemd-tmpfiles --create /etc/tmpfiles.d/logwisp.conf || die "cannot create the logwisp directories"
fi
_dropin=''
case $FILE_DIR in '' | /var/log/logwisp | /var/lib/logwisp) ;; *) _dropin="${_dropin}ReadWritePaths=\"$FILE_DIR\"$NL" ;; esac
case $NL$LOG_DIRS$NL$FILE_DIR in *"$NL/home"* | *"$NL/root"* | *"$NL/run/user"*) _dropin="${_dropin}ProtectHome=read-only$NL" ;; esac
[ -z "$LOG_GROUP" ] || _dropin="${_dropin}SupplementaryGroups=$LOG_GROUP$NL"
for _p in $CHAIN_PORT $HTTP_PORT $TCP_PORT; do
[ "$ROLE" = edge ] && break
if [ "$_p" -lt 1024 ]; then
_dropin="${_dropin}CapabilityBoundingSet=CAP_NET_BIND_SERVICE${NL}AmbientCapabilities=CAP_NET_BIND_SERVICE$NL"
break
fi
done
_d=/etc/systemd/system/logwisp.service.d
if [ -n "$_dropin" ]; then
run install -d -m 0755 "$_d" || exit 1
put_file "$_d/deploy.conf" 0644 <<EOF
# Written by deploy/lw-deploy.sh: this node's paths, groups and ports.
[Service]
$_dropin
EOF
elif [ -f "$_d/deploy.conf" ]; then
run rm -f "$_d/deploy.conf"
fi
}
freebsd_prepare() {
pick_bin /usr/local/bin
trun install -d -m 0755 "$BINDIR" /usr/local/etc/rc.d || exit 1
[ -z "$BIN_SRC" ] || put_file "$BINDIR/lw" 0755 "$BIN_SRC"
if [ "$DRY_RUN" = 1 ] || ! tprobe pw usershow logwisp >/dev/null 2>&1; then
trun pw useradd logwisp -d /nonexistent -s /usr/sbin/nologin -c "LogWisp log transport" ||
die "cannot create the logwisp user"
fi
[ -z "$LOG_GROUP" ] || trun pw groupmod "$LOG_GROUP" -m logwisp || die "cannot add logwisp to $LOG_GROUP"
_rc=$(pkg_text logwisp.rc "s|%%BINDIR%%|$BINDIR|g; s|%%PREFIX%%|/usr/local|g") || exit 1
put_file /usr/local/etc/rc.d/logwisp 0555 <<EOF
$_rc
EOF
}
build_image() {
set -- "$ENGINE" build
[ -z "$BUILD_CA" ] || set -- "$@" --secret "id=build_ca,src=$BUILD_CA"
for _v in HTTPS_PROXY HTTP_PROXY NO_PROXY https_proxy http_proxy no_proxy; do
eval "_set=\${$_v+x}"
[ -z "$_set" ] || set -- "$@" --build-arg "$_v"
done
IFS=' '
# shellcheck disable=SC2086 # the flags are split on spaces by design
set -- "$@" $BUILD_FLAGS
IFS=$OIFS
_ver=$(git -C "$REPO" describe --tags --always 2>/dev/null) || _ver=dev
_rev=$(git -C "$REPO" rev-parse HEAD 2>/dev/null) || _rev=unknown
run "$@" --build-arg "VERSION=$_ver" --build-arg "REVISION=$_rev" -t "$IMAGE" "$REPO" ||
die "the image build failed (behind a proxy: --build-ca FILE, --build-flags \"--network host\")"
}
# --check builds every plugin as a start would, reading its TLS and credentials files.
conf_check() { # COMMAND...: lw, or the container that runs it
run "$@" --check -c "$CONF_LW/logwisp.toml" ||
die "lw --check rejects $CONF_HOST/logwisp.toml${OLD_CONF:+ (the previous one is logwisp.toml.bak)}"
}
docker_start() {
set -- --read-only --cap-drop ALL --security-opt no-new-privileges -u "$IMAGE_USER"
[ -z "$USERNS" ] || set -- "$@" --userns "$USERNS"
[ -z "$LOG_GID" ] || set -- "$@" --group-add "$LOG_GID"
set -- "$@" -v "$CONF_HOST:/etc/logwisp:ro"
IFS=$NL
for _d in $LOG_DIRS; do set -- "$@" -v "$_d:$_d:ro"; done
IFS=$OIFS
[ -z "$FILE_DIR" ] || set -- "$@" -v "$FILE_DIR:$FILE_DIR"
conf_check "$ENGINE" run --rm --network none "$@" "$IMAGE"
_net=''
case $NETWORK in
'' | host | bridge | none) ;;
*) "$ENGINE" network inspect "$NETWORK" >/dev/null 2>&1 || _net=$NETWORK ;;
esac
if [ "$START" = yes ]; then
[ -z "$_net" ] || run "$ENGINE" network create "$_net" || die "cannot create network $_net"
[ -z "$OLD_CTR" ] || run "$ENGINE" rm -f "$NAME" || die "cannot remove container $NAME"
fi
[ -z "$NETWORK" ] || set -- "$@" --network "$NETWORK"
if [ "$NETWORK" != host ] && [ "$ROLE" != edge ]; then
for _p in $CHAIN_PORT $HTTP_PORT $TCP_PORT; do
set -- "$@" -p "$(urlhost "$LISTEN"):$_p:$_p"
done
fi
set -- "$ENGINE" run -d --name "$NAME" --restart unless-stopped "$@" "$IMAGE" -c /etc/logwisp/logwisp.toml
if [ "$START" = no ]; then
note "Start it with:"
[ -z "$_net" ] || note "+ $ENGINE network create $_net"
show_cmd "$@"
printf '\n'
return 0
fi
run "$@" || die "the container did not start"
[ "$DRY_RUN" = 1 ] && return 0
sleep 3
_state=$("$ENGINE" inspect -f '{{.State.Running}} {{.RestartCount}}' "$NAME" 2>/dev/null)
"$ENGINE" logs --tail 15 "$NAME" 2>&1 | sed 's/^/ | /'
[ "$_state" = "true 0" ] || die "container $NAME is not running cleanly; see: $ENGINE logs $NAME"
note "container $NAME is running"
}
native_start() {
if [ "$TARGET_OS" = freebsd ]; then
if [ "$START" = no ]; then
note "Start it with: ${JAIL:+jexec $JAIL }sysrc logwisp_enable=YES && ${JAIL:+jexec $JAIL }service logwisp start"
return 0
fi
trun sysrc logwisp_enable=YES || die "sysrc failed"
trun service logwisp restart || die "the service did not start; see /var/log/messages"
[ "$DRY_RUN" = 1 ] || { sleep 2; tprobe service logwisp status || die "the service is not running; see /var/log/messages"; }
return 0
fi
run systemctl daemon-reload || exit 1
if [ "$START" = no ]; then
note "Start it with: systemctl enable --now logwisp"
return 0
fi
run systemctl enable logwisp || exit 1
run systemctl restart logwisp || die "the service did not start: journalctl -u logwisp"
[ "$DRY_RUN" = 1 ] && return 0
sleep 2
systemctl is-active --quiet logwisp || { journalctl -u logwisp -n 20 --no-pager; die "the service is not running"; }
note "service logwisp is running"
}
next_steps() {
note ""
note "Next:"
case $RUNTIME in
docker)
note " logs: $ENGINE logs -f $NAME"
note " reload: edit $CONF_HOST/logwisp.toml, then $ENGINE kill -s HUP $NAME"
;;
native)
if [ "$TARGET_OS" = freebsd ]; then note " logs: /var/log/messages${JAIL:+ in jail $JAIL} (tag logwisp)"; else note " logs: journalctl -u logwisp -f"; fi
if [ "$TARGET_OS" = freebsd ]; then _r="${JAIL:+jexec $JAIL }service logwisp reload"; else _r="systemctl reload logwisp"; fi
note " reload: edit $CONF_LW/logwisp.toml, then $_r"
;;
manual) note " run: ${LW:-lw} -c $CONF_LW/logwisp.toml" ;;
esac
if [ -n "$HTTP_PORT" ]; then
_h=$LISTEN
[ "$_h" = 0.0.0.0 ] && _h=127.0.0.1
[ "$_h" = :: ] && _h=::1
if [ "$TLS" = yes ]; then _u=https; else _u=http; fi
_u=$_u://$(urihost "$_h"):$HTTP_PORT
case $SINK_AUTH in
none)
_c=''
[ "$TLS" = no ] || _c='--cacert CA '
note " status: curl $_c$_u/status"
;;
mtls) note " status: curl --cacert CA --cert CERT --key KEY $_u/status" ;;
scram) note " status: curl with a bearer token from: lw auth token --url $_u --user USER --password-file FILE --ca-file CA (doc/cli.md)" ;;
esac
note " stream: $_u/stream"
fi
if [ -n "$ADD_USERS" ]; then
note " edges: copy $SECRETS_DIR/USER.pass to each edge over a safe channel, then run"
note " there: $PROG --role edge --tls --auth scram --username USER --password-file FILE ..."
fi
[ -z "$ADD_VIEWERS" ] || note " viewers: lw auth token / lw auth stream with $SECRETS_DIR/USER.pass (doc/cli.md)"
if [ "$ROLE" != standalone ] && [ "$AUTH" = none ]; then
note " without chain auth the aggregator admits any peer and trusts the node label it claims"
fi
if [ "$SINK_AUTH" = none ] && [ -n "$HTTP_PORT$TCP_PORT" ]; then
note " the http/tcp outputs admit anyone who reaches them: bind --listen to a trusted address"
fi
}
main() {
resolve
derive
plan
if [ "$DRY_RUN" = 1 ]; then
note "Dry run: nothing below is changed."
note ""
elif [ "$INTERACTIVE" = 1 ]; then
_go=''
ask_yn _go "Apply?" yes
[ "$_go" = yes ] || exit 0
elif [ -n "$OLD_CONF$OLD_CTR" ] && [ "$FORCE" != yes ]; then
die "${OLD_CONF:-container $NAME} exists (--force replaces it)"
fi
keep_passwords users.toml "$ADD_USERS"
keep_passwords viewers.toml "$ADD_VIEWERS"
case $RUNTIME in
docker)
[ "$DRY_RUN" = 1 ] || "$ENGINE" info >/dev/null 2>&1 || die "cannot reach $ENGINE"
[ "$BUILD" = no ] || build_image
;;
native)
[ "$DRY_RUN" = 1 ] || [ "$(id -u)" = 0 ] || die "a native install needs root"
if [ "$BUILD" = yes ]; then
run make -C "$REPO" build || die "make build failed"
BIN=$REPO/bin/lw
fi
if [ "$TARGET_OS" = freebsd ]; then freebsd_prepare; else linux_prepare; fi
;;
manual)
LW=$BIN
[ -n "$LW" ] || LW=$(command -v lw 2>/dev/null) || LW=''
[ -n "$LW" ] || LW=$REPO/bin/lw
[ -z "$ADD_USERS$ADD_VIEWERS" ] || [ -x "$LW" ] || [ "$DRY_RUN" = 1 ] || die "no lw binary to add users with: --bin FILE"
;;
esac
write_files
[ -z "$ADD_USERS$ADD_VIEWERS" ] || run install -d -m 0700 "$SECRETS_DIR" || die "cannot create $SECRETS_DIR"
add_users users.toml "$ADD_USERS"
add_users viewers.toml "$ADD_VIEWERS"
case $RUNTIME in
docker) docker_start ;;
native)
if [ -n "$JAIL" ]; then conf_check jexec "$JAIL" "$BINDIR/lw"; else conf_check "$BINDIR/lw"; fi
native_start
;;
manual) [ ! -x "$LW" ] || conf_check "$LW" ;;
esac
next_steps
}
main