Compare commits

..

4 Commits

Author SHA1 Message Date
9c308cef90 add whisper 2026-03-22 22:47:51 +03:00
5bb50a842d additional params 2026-02-23 17:14:17 +03:00
90f87d8f40 initial commit 2026-02-16 19:38:56 +03:00
62e2ea74be select which transport to use by default 2025-10-23 15:17:44 +03:00
11 changed files with 500 additions and 9 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
login-mailer.service
many-rsync/test-area
many-rsync/sync.toml

45
many-rsync/README.md Normal file
View File

@@ -0,0 +1,45 @@
# server-toolset
## many-rsync
rsync a set of folders to the remote in parallel.
### config
use TOML (preferred) or JSON.
```toml
# parallel rsync configuration
# remote_folder: full rsync-compatible remote path
# don't forget the trailing slash!
# /Users/foo/target/
# foo@bar:/home/foo/target/
remote_folder = ""
# local_folders: bare folder names (resolved relative to $HOME)
local_folders = ["a"]
# n: max parallel rsync processes (default: 2)
n = 2
# log_level: pick from DEBUG | INFO | WARNING | ERROR | CRITICAL
log_level = "INFO"
# use to pass arguments to the rsync binary running locally
# see rsync help/manpage for details
[rsync_parameters]
# --rsync-path
rsync_path = "/usr/bin/rsync"
# --exclude-from
exclude_from = ".rsync-exclude.txt"
```
- `remote_folder`: rsync-compatible full path to the target folder in remote. "remote" here means that it's the target of the operation, and could still reside on the local system.
- **do not forget to use the trailing slash!**
- `local_folders`: each folder to be copied over
- `n`: how many parallel rsync routines to be spawned
- `log_level`: self explanatory
- `rsync_parameters`: exposes local rsync binary's options
- `rsync_path`: specify the rsync to run on remote machine
- `exclude_from`: read exclude patterns from FILE

View File

@@ -0,0 +1,24 @@
# many-rsync configuration
# remote_folder: full rsync-compatible remote path
# don't forget the trailing slash!
# /Users/foo/target/
# foo@bar:/home/foo/target/
remote_folder = ""
# local_folders: bare folder names (resolved relative to $HOME)
local_folders = []
# n: max parallel rsync processes (default: 2)
n = 2
# log_level: pick from DEBUG | INFO | WARNING | ERROR | CRITICAL
log_level = "INFO"
# use to pass arguments to the rsync binary running locally
# see rsync help/manpage for details
[rsync_parameters]
# --rsync-path
rsync_path = "/usr/bin/rsync"
# --exclude-from
exclude_from = ".rsync-exclude.txt"

175
many-rsync/main.py Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
many-rsync
author Yigid BALABAN <balaban@yigid.dev>
co-authored by Opus 4.6
parallel rsync runner. reads config from TOML (preferred) or JSON.
local: foo bar --|many-rsync|--> remote/foo remote/bar
"""
import json
import logging
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, TypedDict, Literal
class RsyncParameters(TypedDict, total=False):
rsync_path: str
exclude_from: str
class Config(TypedDict):
local_folders: list[Path]
remote_folder: str
n: int
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
rsync_parameters: RsyncParameters
try:
import tomllib # 3.11+
except ModuleNotFoundError:
tomllib = None
HOME = Path.home()
LOG_DIR = HOME / ".rsync-logs"
RSYNC_BASE = ("rsync", "-avh", "--progress", "--delete", "--stats")
logger = logging.getLogger("sync")
# ── config ────────────────────────────────────────────────────────────────────
def _load_raw(path: Path) -> dict[str, Any]:
text = path.read_text()
if path.suffix == ".toml":
if tomllib is None:
sys.exit("FATAL: Python < 3.11 has no tomllib; install tomli or use JSON config")
return tomllib.loads(text)
if path.suffix == ".json":
return json.loads(text)
sys.exit(f"FATAL: unsupported config format: {path.suffix}")
def load_config(path: Path) -> Config:
raw = _load_raw(path)
folders: list[Any] = raw.get("local_folders", [])
if not folders:
sys.exit("FATAL: local_folders must be a non-empty list")
folders = [Path(f).expanduser() for f in folders]
for f in folders:
if not f.is_dir():
sys.exit(f"FATAL: local_folders entries must exist and be folders, got: {f!r}")
remote = raw.get("remote_folder")
if not remote:
sys.exit("FATAL: remote_folder is required")
remote = str(remote) # keep as str — may be "host:/path", not a local path
n = int(raw.get("n", 1))
if n < 1:
sys.exit("FATAL: n must be >= 1")
if n > len(folders):
n = len(folders) # no point spawning idle workers
level = raw.get("log_level", "INFO").upper()
if level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
sys.exit(f"FATAL: invalid log_level: {level!r}")
rsync_params: RsyncParameters = {}
raw_params = raw.get("rsync_parameters", {})
if rsync_path := raw_params.get("rsync_path"):
rsync_params["rsync_path"] = str(rsync_path)
if exclude_from := raw_params.get("exclude_from"):
rsync_params["exclude_from"] = str(exclude_from)
return {
"local_folders": folders, "remote_folder": remote,
"n": n, "log_level": level, "rsync_parameters": rsync_params,
}
# ── sync ──────────────────────────────────────────────────────────────────────
def _build_rsync_cmd(params: RsyncParameters) -> tuple[str, ...]:
"""Extend RSYNC_BASE with optional flags from config."""
extra: list[str] = []
if rp := params.get("rsync_path"):
extra.append(f"--rsync-path={rp}")
if ef := params.get("exclude_from"):
extra.append(f"--exclude-from={ef}")
return (*RSYNC_BASE, *extra)
def sync_folder(folder: Path, remote: str, ts: str, params: RsyncParameters) -> tuple[Path, int]:
"""Run rsync for a single folder. Returns (folder, returncode)."""
log_file = LOG_DIR / f"{folder.name}-{ts}.log"
cmd: list[str | Path] = [*_build_rsync_cmd(params), f"{folder}", remote]
logger.info("START %s%s (log: %s)", folder, remote, log_file)
with log_file.open("w") as fh:
proc = subprocess.run(
cmd,
cwd=HOME,
stdin=subprocess.DEVNULL, # prevent parallel processes from fighting over terminal input
stdout=fh,
stderr=subprocess.STDOUT, # interleave; nothing goes silent
text=True,
)
if proc.returncode != 0:
logger.error("FAIL %s rc=%d — see %s", folder, proc.returncode, log_file)
else:
logger.info("OK %s", folder)
return folder, proc.returncode
# ── main ──────────────────────────────────────────────────────────────────────
def main() -> None:
cfg_path = Path(sys.argv[1]) if len(sys.argv) > 1 else HOME / "sync.toml"
cfg = load_config(cfg_path)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=cfg["log_level"],
format="%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_DIR / f"rsync-{ts}.log"),
]
)
folders, remote, n = cfg["local_folders"], cfg["remote_folder"], cfg["n"]
params = cfg["rsync_parameters"]
logger.debug("got config: %s", cfg)
logger.info("log level is set to %s", cfg["log_level"])
logger.info("syncing %d folder(s), parallelism=%d", len(folders), n)
failed: list[tuple[Path, int]] = []
with ThreadPoolExecutor(max_workers=n) as pool:
futures = {pool.submit(sync_folder, f, remote, ts, params): f for f in folders}
for fut in as_completed(futures):
folder, rc = fut.result()
if rc != 0:
failed.append((folder, rc))
if failed:
for f, rc in failed:
logger.critical("FAILED: %s (rc=%d)", f, rc)
sys.exit(1)
logger.info("SUCC all folders synced successfully")
if __name__ == "__main__":
main()

View File

@@ -29,6 +29,15 @@ vim /etc/ssh-notify/config.conf # edit in place
chmod 600 /etc/ssh-notify/config.conf
```
**Configuration Options:**
- `EMAIL_RECIPIENT`: Email address to receive notifications
- `EMAIL_API_ENDPOINT`: API endpoint for sending emails
- `TELEGRAM_BOT_TOKEN`: Your Telegram bot token
- `TELEGRAM_CHAT_ID`: Telegram chat ID to receive notifications
- `PAM_TRANSPORTS`: Space-separated list of transports for PAM mode (e.g., `"telegram email"`, `"telegram"`, `"email"`)
- `LOG_FILE`: Path to log file (default: `/var/log/ssh-notify.log`)
### 3. PAM configuration
```sh
@@ -54,13 +63,13 @@ The `ssh-notify.logrotate` tells `logrotate` to rotate `/var/log/sshnotify.lo
Feel free to contact me for collaboration on anything!
Yiğid BALABAN, <[fyb@fybx.dev][llmail]>
Yiğid BALABAN, <[hey@yigid.dev][llmail]>
[My Website][llwebsite] • [X][llx] • [LinkedIn][lllinkedin]
2024
[llmail]: mailto:fyb@fybx.dev
[llwebsite]: https://fybx.dev
[llmail]: mailto:hey@yigid.dev
[llwebsite]: https://yigid.dev
[llx]: https://x.com/fybalaban
[lllinkedin]: https://linkedin.com/in/fybx
[lllinkedin]: https://linkedin.com/in/yigid

View File

@@ -6,5 +6,14 @@ EMAIL_API_ENDPOINT="https://mail-proxy.example.org/api/mail"
TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID=""
# Transport Configuration
# Space-separated list of transports to use when PAM triggers the script
# Valid options: email telegram
# Examples:
# PAM_TRANSPORTS="telegram email" # both
# PAM_TRANSPORTS="telegram" # only Telegram
# PAM_TRANSPORTS="email" # only Email
PAM_TRANSPORTS="telegram email"
# Log file for the notifier script
LOG_FILE="/var/log/ssh-notify.log"

View File

@@ -91,9 +91,47 @@ else
exit 1
fi
# Ensure required config variables are set
if [[ -z "$EMAIL_RECIPIENT" || -z "$EMAIL_API_ENDPOINT" || -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" || -z "$LOG_FILE" ]]; then
ERR_MSG="ssh-notify Error: One or more required variables are missing in $CONFIG_FILE."
# Set default for PAM_TRANSPORTS if not specified
if [[ -z "$PAM_TRANSPORTS" ]]; then
PAM_TRANSPORTS="telegram email"
fi
# Validate and parse PAM_TRANSPORTS
ENABLE_EMAIL=false
ENABLE_TELEGRAM=false
for transport in $PAM_TRANSPORTS; do
case "$transport" in
email)
ENABLE_EMAIL=true
;;
telegram)
ENABLE_TELEGRAM=true
;;
*)
ERR_MSG="ssh-notify Warning: Unknown transport '$transport' in PAM_TRANSPORTS. Valid options: email, telegram"
echo "$ERR_MSG" | systemd-cat -p warning -t 'ssh-notify'
echo "$ERR_MSG" >&2
;;
esac
done
# Ensure required config variables are set based on enabled transports
if [[ "$ENABLE_EMAIL" == true && ( -z "$EMAIL_RECIPIENT" || -z "$EMAIL_API_ENDPOINT" ) ]]; then
ERR_MSG="ssh-notify Error: Email transport enabled but EMAIL_RECIPIENT or EMAIL_API_ENDPOINT missing in $CONFIG_FILE."
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
echo "$ERR_MSG" >&2
exit 1
fi
if [[ "$ENABLE_TELEGRAM" == true && ( -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ) ]]; then
ERR_MSG="ssh-notify Error: Telegram transport enabled but TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID missing in $CONFIG_FILE."
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
echo "$ERR_MSG" >&2
exit 1
fi
if [[ -z "$LOG_FILE" ]]; then
ERR_MSG="ssh-notify Error: LOG_FILE missing in $CONFIG_FILE."
echo "$ERR_MSG" | systemd-cat -p err -t 'ssh-notify'
echo "$ERR_MSG" >&2
exit 1
@@ -162,8 +200,8 @@ send_telegram() {
[[ "$TEST_TYPE" == "both" || "$TEST_TYPE" == "email" ]] && send_email
[[ "$TEST_TYPE" == "both" || "$TEST_TYPE" == "telegram" ]] && send_telegram
else
send_email
send_telegram
[[ "$ENABLE_EMAIL" == true ]] && send_email
[[ "$ENABLE_TELEGRAM" == true ]] && send_telegram
fi
) &

2
whisper/example.env Normal file
View File

@@ -0,0 +1,2 @@
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=

89
whisper/install.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/sh
set -e
BIN_SRC="./whisper"
BIN_DST="/usr/bin/whisper"
CONF_DIR="/etc/whisper"
CONF_DST="$CONF_DIR/.env"
CONF_SRC="./example.env"
die() {
printf '%s\n' "$1" >&2
exit 1
}
usage() {
printf 'Usage: install.sh [-i|--install] [-u|--uninstall] [-h|--help]\n'
printf ' -i, --install Install whisper (default)\n'
printf ' -u, --uninstall Remove whisper\n'
printf ' -h, --help Show this help\n'
exit 0
}
do_install() {
printf 'This will:\n'
printf ' - Copy whisper to %s\n' "$BIN_DST"
printf ' - Create %s/ with config\n\n' "$CONF_DIR"
printf 'Proceed? [Y/n] '
read -r answer
case "$answer" in
[nN]) printf 'Aborted.\n'; exit 0 ;;
esac
[ -f "$BIN_SRC" ] || die "Cannot find $BIN_SRC in current directory"
[ -f "$CONF_SRC" ] || die "Cannot find $CONF_SRC in current directory"
cp "$BIN_SRC" "$BIN_DST"
chown root:root "$BIN_DST"
chmod 755 "$BIN_DST"
mkdir -p "$CONF_DIR"
if [ ! -f "$CONF_DST" ]; then
cp "$CONF_SRC" "$CONF_DST"
else
printf 'Keeping existing %s\n' "$CONF_DST"
fi
chown root:root "$CONF_DST"
chmod 600 "$CONF_DST"
printf '\nInstalled. Edit %s and set your values.\n' "$CONF_DST"
}
do_uninstall() {
printf 'This will remove:\n'
printf ' - %s\n' "$BIN_DST"
printf ' - %s/\n\n' "$CONF_DIR"
printf 'Proceed? [y/N] '
read -r answer
case "$answer" in
[yY]) ;;
*) printf 'Aborted.\n'; exit 0 ;;
esac
rm -f "$BIN_DST"
rm -rf "$CONF_DIR"
printf 'Done.\n'
}
# --- main ---
[ "$(id -u)" -eq 0 ] || die "Must be run as root"
ACTION="install"
while [ $# -gt 0 ]; do
case "$1" in
-i|--install) ACTION="install"; shift ;;
-u|--uninstall) ACTION="uninstall"; shift ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
case "$ACTION" in
install) do_install ;;
uninstall) do_uninstall ;;
esac

16
whisper/test.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# manual smoke tests for whisper via telegram
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
echo '--- test 1: plain text ---\n'
"$SCRIPT_DIR/whisper" -t telegram -m "Hello from whisper test" || true
echo '\n--- test 2: HTML formatting ---\n'
"$SCRIPT_DIR/whisper" -t telegram -m "<b>bold</b> <i>italic</i> <code>code</code>" || true
echo '\n--- test 3: multiline ---\n'
MSG=$(printf 'Line one\nLine two\nLine three')
"$SCRIPT_DIR/whisper" -t telegram -m "$MSG" || true
echo '\nAll tests done.\n'

82
whisper/whisper Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/sh
# whisper - send messages via transport (telegram)
set -e
die() {
printf 'whisper: %s\n' "$1" >&2
exit "${2:-1}"
}
usage() {
printf 'Usage: whisper -m MESSAGE [-t TRANSPORT]\n\n'
printf ' -m, --message Message to send (required)\n'
printf ' -t, --transport Transport to use (default: telegram)\n'
printf ' -h, --help Show this help\n'
exit 0
}
# --- config loading ---
# source .env files if they exist (later file wins, env vars win over both)
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
for _envfile in /etc/whisper/.env "$SCRIPT_DIR/.env"; do
[ -f "$_envfile" ] && . "$_envfile"
done
unset _envfile
# --- dependency check ---
command -v curl >/dev/null 2>&1 || die "curl is required but not found" 1
# --- arg parsing ---
TRANSPORT="telegram"
MESSAGE=""
while [ $# -gt 0 ]; do
case "$1" in
-m|--message)
[ -n "${2+x}" ] || die "missing value for $1" 1
MESSAGE="$2"; shift 2 ;;
-t|--transport)
[ -n "${2+x}" ] || die "missing value for $1" 1
TRANSPORT="$2"; shift 2 ;;
-h|--help)
usage ;;
*)
die "unknown option: $1" 1 ;;
esac
done
[ -n "$MESSAGE" ] || die "message is required (-m)" 1
# --- env validation ---
validate_telegram_env() {
[ -n "$TELEGRAM_BOT_TOKEN" ] || die "TELEGRAM_BOT_TOKEN is not set" 1
[ -n "$TELEGRAM_CHAT_ID" ] || die "TELEGRAM_CHAT_ID is not set" 1
}
# --- transports ---
send_telegram() {
validate_telegram_env
local url="https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage"
local response
response=$(curl -s -X POST "$url" \
-d chat_id="$TELEGRAM_CHAT_ID" \
--data-urlencode text="$MESSAGE" \
-d parse_mode="HTML")
if echo "$response" | grep -q '"ok":true'; then
printf 'whisper: message sent\n' >&2
return 0
else
local err
err=$(echo "$response" | sed -n 's/.*"description":"\([^"]*\)".*/\1/p')
printf 'whisper: send failed: %s\n' "${err:-unknown error}" >&2
exit 2
fi
}
# --- dispatch ---
case "$TRANSPORT" in
telegram) send_telegram ;;
*) die "unknown transport: $TRANSPORT" 1 ;;
esac