.
gates / consistency-and-conventions (push) Failing after 9s

This commit is contained in:
Your Name
2026-08-21 09:53:10 -04:00
parent 364c5c3687
commit a0152fa87c
5 changed files with 302 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
1
+3 -2
View File
@@ -10,17 +10,18 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
- @DOC/DEV.md — conventions, verification, and the "Adding a new Feature/App/Tool" checklists. Read before creating or changing code/docs.
- @DOC/POS.md — `pos` CLI reference (dispatcher + every command). Read when working on `bin/pos*` scripts or their docs.
- @DOC/README.md — index of all docs. Read to find the right doc.
- @DOC/HOWTO.md — hands-on per-category guides (network, docker, media, system, ssh, usb, communication, entertainment). Read when a task is about *using* `pos` day-to-day rather than extending it.
- @DOC/HOWTO.md — hands-on per-category guides (ai, network, docker, media, system, ssh, share, communication, entertainment). Read when a task is about *using* `pos` day-to-day rather than extending it.
## Quick facts
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers.
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI: `.gitea/workflows/lint.yml` runs `make gen` + `git diff --exit-code` + `make check` + `make lint` on every push/PR via the **live** Gitea act_runner (`linux-post-install`, registered on the Gitea host) — a red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/<sha>` or `ci-fail/<sha>` (pushes only — check remote tags to see gate status). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
- **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt.
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES.
- **Secrets:** never commit keys/tokens. `config/authorized_keys` and `config/rclone.conf` are gitignored; runtime tool config is `~/.config/linux_post_install/<tool>.env` (chmod 600, env-var precedence). Mask tokens in `config` output.
- **entertainment plugins:** standalone scripts in `entertainment/` that must NOT source `lib/common.sh` — stdout is the message that gets sent to Telegram (helper chatter would leak into it). Markers: `# POS_PLUGIN: <name>` + `# POS_KEYS:` declarations. They aren't `pos-*` tools, so `make gen` skips them (no headers/doc tables) — verify with `bash -n` + a live `pos entertainment send <name> --print`.
- **ScaleTail templates** are a git submodule (`compose/scale-tail`), absent on fresh clones — run `git submodule update --init` first (only needed for `pos docker compose *`).
- **Doc conflicts:** resolve by the authority order in `MAINTENANCE.md → Phase 0`: `templates/*.sh` are the codified current convention and the required starting point for new work (`cp templates/pos-tool.sh bin/pos-<cat>-<cmd>`, likewise `app.sh` / `feature.sh`); then DEV.md wins on detail, AGENTS.md on process facts; code + `# POS:` headers are ground truth for behavior and all `GEN:` blocks; drift in POS/HOWTO/README/SCRIPTS/SYSTEMD/APPS is a doc bug — fix the doc.
- **Conventions:** `set -euo pipefail`, `-h|--help` via case, idempotent writes, use `run`/`spawn` helpers (respect `$DRY_RUN`), `make hook` installs the opt-in pre-commit gate. `command -v` deps guards sit **before** the `-h|--help` dispatch — help also errors on a box missing the dependency (matches all existing deps-gated tools). Tools must run standalone from `/usr/local/bin` after install (source `lib/common.sh` via the `$(dirname "$0")/../lib/common.sh` fallback chain). Commits use conventional prefixes (`feat:`/`fix:`/`docs:`/`chore:`/`refactor:`).
- Maintain `AGENT_TODO.md` (Now / Next / Later / Done): when you finish a task, move it to **Done** (dated) in the same commit.
+1
View File
@@ -42,6 +42,7 @@ summary (newest last).
## Done
- **2026-08-21** — Refreshed `AGENTS.md` against the codebase: HOWTO category list corrected to match `DOC/howto/*` (ai/share/schedule, no bare "usb"); CI bullet now states only verifiable facts (`lint.yml` job `gates`, push-to-main/PR, `ci-ok/<sha>`/`ci-fail/<sha>` result tags) instead of the uncheckable act-runner naming; new **Doc conflicts** bullet encoding the `MAINTENANCE.md → Phase 0` authority order and `templates/*.sh` as required starting points. Every other claim re-verified against `scripts/{gen-docs,check-sync,lint-conventions}.sh`, `bin/pos` (dispatch loop, INTERACTIVE_CMDS), `.gitignore`/`.gitmodules`, `lib/config-ui.sh`; gates green before and after.
- **2026-08-15** — `pos docker stack` (`bin/pos-docker-stack`) — containers grouped by their Docker Compose project. Each stack is a section (project name, sorted) with lines `container-name status ports`; containers with no compose project land in a `Standalone` section at the end; ends with `Stacks: N containers: N standalone: N`. Running only by default, `-a|--all` includes stopped/exited (like `docker ps -a`). Status colored on a terminal (`Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow); exit 0 also when no containers. Data via `docker ps` with `--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}'` (compose v2 sets the project label; `{{"\u001f"}}` escapes in the Go template), parsed with `awk -F'\x1f'` + `IFS=$'\x1f' read` everywhere — tab/pipe delimiters are IFS whitespace or inside values, so `\x1f` (DEV.md:213 gotcha); dash padding via `sed` not `tr` (tr corrupts multi-byte `─`). Deps guard (`docker`) before `--help`; no stdin → not in `INTERACTIVE_CMDS`; `# POS_FLAGS: -a --all`. Docs: POS.md docker row + detail, howto/docker.md table + section, `bin/pos` usage EXAMPLES, AGENT_Context §14 row. Verified: stub-PATH suite `/tmp/opencode/docker-stack-test/run-tests.sh` 23/23 (grouping, sorted stacks, `-a` shows exited, standalone, empty daemon rc=0, colored status, missing docker rc=1, `--help` after deps guard); live runs against the real daemon (affine/audiobookshelf/convertx/gitea stacks, `affine_migration_job Exited (0)` + `lab1 Exited (137)` under `-a`); dispatch via `pos docker stack`; `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
- **2026-08-15** — Fix `pos media sync` offering a Ventoy stick's **EFI partition** as the sync target: with the data partition unmounted, the 32 MB `VTOYEFI` ESP was the only mounted USB partition, `usb_detect` offered it with no context, and `cp` died mid-copy with `No space left on device` (live-box report). `usb_detect` now fetches `FSTYPE`/`PARTTYPENAME` and excludes EFI system partitions (Ventoy `VTOYEFI`, `/boot/efi`) from **both** the mounted list and the mount-offer list; `USB_MOUNTED` entries carry `mp|label|size|model|fs` and `usb_pick_root` shows that in the single-stick confirm and the multi-stick/partition picker (`1) /media/Ventoy (1.1T, Ventoy, exfat)`), while `USB_ROOT` stays a bare mountpoint (`${root%|*}`) so `pos system backup` (`${root%/}/backups`) is unaffected. `pos-media-sync` gained a pre-flight space check (measures exactly what `needs_copy` would copy vs `df -Pk`, `err`/`warn` before any copy) — no more mid-copy ENOSPC. Docs: howto/media.md target-picking note, SCRIPTS.md usb-lib paragraph, AGENT_Context hand-maintained lib row (194→205). Verified: new stub harness `/tmp/opencode/vtoyefi-run.sh` (ESP filtered from mounted + mount-offer, multi-pick shows only the data partition, space fit/too-small/dry-run-warn) green; `/tmp/opencode/backup-test` still green; live check `printf 'n\ns\n' | bash bin/pos-media-sync --mp3` no longer offers VTOYEFI (offers unmounted `sda1` Ventoy instead); `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
- **2026-08-15** — Fix `pos media sync` reporting success with 0 files when the source is a symlink: it enumerated with plain `find "$SRC"`, and GNU find (default `-P`) does not descend a command-line symlink to a directory — `~/Music -> /mnt/hdd/…/music` therefore yielded zero matches, the loop never ran, and the tool printed `0 added, 0 updated, 0 unchanged` without creating the target dir (live-box report). Switched to `find -H "$SRC"` (follows only command-line symlinks; inner-symlink semantics unchanged). howto/media.md sync section notes symlinked sources are followed. Caught live, not by the 46-case stub suite (which used a real temp dir source — lesson: add a symlink-root fixture). Verified: `printf 'y\n' | bash bin/pos-media-sync --mp3 --dry-run` now lists all 31 mp3s as "would copy"; `make gen && make check` green.
+239 -16
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: share smb-client — Mount SMB/CIFS shares (ephemeral or persistent systemd mount units)
# POS_SUBCMDS: mount unmount list persist unpersist
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
SMB_PORT="${SMB_PORT:-445}"
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
@@ -18,12 +20,23 @@ Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
Commands:
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
unmount <local-dir> Unmount the share
unmount <local-dir> Unmount the share (idempotent)
list Show active + persistent SMB mounts
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
units (mounts on first access — never blocks boot)
unpersist <local-dir> Stop, disable and remove the mount/automount units
Safety net before anything is mounted:
- server reachability on port 445 is probed first (fast fail with hints,
before any password is asked)
- an already-mounted target refuses a second mount instead of stacking
- mounting over a non-empty directory asks for confirmation first
- every successful mount is read-tested immediately; a mount that cannot
be listed is rolled back automatically
- mount failures are translated into targeted fixes (password, chmod o+x
traversal, wrong share name, firewall/down server) instead of raw
kernel errors
With no user, guest access is attempted. With a user, you are prompted for
the Samba password — one-shot mounts use a throwaway chmod-600 credentials
file; persistent mounts keep one at /etc/samba/credentials/.
@@ -47,7 +60,8 @@ esac
validate_share() {
case "$1" in
//*) ;;
//*/*) ;;
//*) err "Invalid share '$1' — missing share name: expected <//server/share> (e.g. //10.0.0.5/media)" ;;
*) err "Invalid share '$1' — expected <//server/share> (e.g. //10.0.0.5/media)" ;;
esac
}
@@ -59,11 +73,57 @@ validate_dir() {
esac
}
# Prompt for the Samba password (no echo, via /dev/tty) and write a fresh
# chmod-600 credentials file; prints its path, caller removes it.
# Split //server/share into server + share; normalizes trailing slashes.
split_share() {
local rest="$1"
rest="${rest#//}"
SHARE_PATH="/${rest#*/}"
SERVER="${rest%%/*}"
SHARE_PATH="${SHARE_PATH%/}"
[ -n "$SERVER" ] || err "No server in share '$1'"
[ -n "${SHARE_PATH#/}" ] || err "No share name in '$1'"
}
# TCP connect probe against the server before touching passwords or mounts.
probe_server() {
local host="$1"
if timeout 3 bash -c "exec 3<>/dev/tcp/${host}/${SMB_PORT}" 2>/dev/null; then
return 0
fi
err "Server ${host} not reachable on port ${SMB_PORT} (SMB) — nothing was mounted.
Checks:
- is the address right? ping ${host}
- is the server up and sharing? on the server: pos share smb-server status
- port filtered? from here: pos network checkport ${host}:${SMB_PORT}
- Tailscale host? make sure 'tailscale status' lists it (up + recent)"
}
# Source currently cifs-mounted at $1 (empty string when none).
mounted_src() {
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
}
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
ensure_mountpoint() {
local where="$1"
if [ -e "$where" ] && [ ! -d "$where" ]; then
err "$where exists and is not a directory — pick another mount point"
fi
if [ -d "$where" ] && [ -n "$(ls -A "$where" 2>/dev/null)" ]; then
warn "$where is not empty — its contents are hidden while the share is mounted."
confirm "Mount over it anyway?" n || err "Aborted — nothing was mounted"
elif [ ! -d "$where" ]; then
run sudo mkdir -p "$where"
log "Created mount point $where"
fi
}
# Prompt for the Samba password (no echo) and write a fresh chmod-600
# credentials file; prints its path, caller removes it. Test seam:
# SMB_PW_FILE redirects the password read (never advertised).
make_creds() {
local user="$1" pw tmp
read -rsp "Samba password for $user: " pw </dev/tty || true
local user="$1" pw tmp pw_in="${SMB_PW_FILE:-/dev/tty}"
read -rsp "Samba password for $user: " pw <"$pw_in" || true
echo >&2
[ -n "$pw" ] || err "empty password"
tmp="$(mktemp)"
@@ -73,10 +133,73 @@ make_creds() {
}
mount_opts() {
local user="$1"
printf 'uid=%s,gid=%s' "$(id -u)" "$(id -g)"
}
# Translate a failed mount attempt into the fix the human actually needs.
diagnose_mount_failure() {
local msg="$1"
case "$msg" in
*LOGON_FAILURE*|*WRONG_PASSWORD*)
err "Server rejected the login (wrong user/password).
- retry with the correct Samba user: pos share smb-client mount $what $where <user>
- does the account exist at all? on the server: pos share smb-server status" ;;
*ACCESS_DENIED*|*"error(13)"*|*"Permission denied"*|*STATUS_CANNOT_DELETE*)
err "Access denied by the server (login worked, share said no).
On the server side, in order:
1. traversal: every parent dir of the share needs o+x — the server tool
prints the exact dirs when you re-share; quick fix:
chmod o+x <parent-dir>
2. user missing from the Samba passdb: pos share smb-server adduser <user>
3. share restricted: 'valid users' must contain your user (pos share smb-server list)" ;;
*BAD_NETWORK_NAME*)
err "The server has no share named '${SHARE_PATH#/}'.
- typo in the share name? on the server: pos share smb-server list
- correct syntax: //$SERVER/<share>" ;;
*CONNECTION_REFUSED*|*"error(111)"*)
err "${SERVER} refused the connection — smbd is down or a firewall rejects port ${SMB_PORT}.
on the server: pos share smb-server enable / sudo ufw allow Samba" ;;
*HOST_UNREACHABLE*|*"error(101)"*|*"error(113)"*|*"No route to host"*|*"Network is unreachable"*)
err "${SERVER} is unreachable (routing/host down).
- Tailscale host? 'tailscale status' must list it as online
- probe from here: pos network checkport ${SERVER}:${SMB_PORT}" ;;
*IO_TIMEOUT*|*"Host is down"*|*"error(112)"*|*"timed out"*)
err "${SERVER} accepted TCP but never answered the SMB handshake (usually a dropping firewall).
on the server: sudo ufw allow Samba / pos share smb-server status" ;;
*)
err "Mount failed:
$msg
More causes + fixes: DOC/howto/share.md (SMB troubleshooting)" ;;
esac
}
# Mount attempt with captured stderr (so failures can be diagnosed).
try_mount() {
local what="$1" where="$2" opts="$3"
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) mount -t cifs $what $where -o $opts"
return 0
fi
MOUNT_ERR="$(sudo mount -t cifs "$what" "$where" -o "$opts" 2>&1)" || return 1
}
# Post-mount proof: the mount must actually list. A mount that cannot be
# read is worse than no mount — roll it back.
read_test_or_rollback() {
local where="$1" what="$2"
if [ "${DRY_RUN:-0}" -eq 1 ]; then return 0; fi
if timeout 5 ls -A "$where" >/dev/null 2>&1; then
return 0
fi
warn "Mounted, but $where cannot be listed — rolling back."
sudo umount "$where" 2>/dev/null || sudo umount -l "$where" 2>/dev/null || true
err "Share '${what}' mounted but reading it failed, so it was unmounted again.
Login works but file permissions don't — on the server:
- ls -la <shared-path> as root: do others/the owner have r-x?
- the connecting user must have unix-level access to the folder itself
Details: DOC/howto/share.md (SMB troubleshooting)"
}
case "$cmd" in
mount)
what="${2:-}"
@@ -85,17 +208,36 @@ case "$cmd" in
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
Unmount it first: pos share smb-client unmount $where"
fi
ensure_mountpoint "$where"
sudo mkdir -p "$where"
if [ -n "$user" ]; then
creds="$(make_creds "$user")"
trap 'rm -f "$creds"' EXIT
sudo mount -t cifs "$what" "$where" -o "credentials=$creds,$(mount_opts "$user")"
opts="credentials=$creds,$(mount_opts)"
try_mount "$what" "$where" "$opts" || {
rm -f "$creds"
trap - EXIT
diagnose_mount_failure "${MOUNT_ERR:-}"
}
rm -f "$creds"
trap - EXIT
else
warn "No user — attempting guest mount (works only if the server allows guest access)"
sudo mount -t cifs "$what" "$where" -o "guest,$(mount_opts "")"
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
fi
log "Mounted $what at $where"
read_test_or_rollback "$where" "$what"
log "Mounted $what at $where (read test passed)"
notify_send "SMB mounted: $what → $where"
;;
unmount)
@@ -103,8 +245,21 @@ case "$cmd" in
[ -n "$where" ] || err "Usage: pos share smb-client unmount <local-dir>"
validate_dir "$where"
sudo umount "$where"
src="$(mounted_src "$where")"
if [ -z "$src" ]; then
log "Nothing mounted at $where"
exit 0
fi
if ! out="$(sudo umount "$where" 2>&1)"; then
if grep -qE "busy|in use" <<<"$out"; then
warn "$where is busy (${src})"
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; exit 0; }
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
fi
err "Unmount failed: $out"
fi
log "Unmounted $where"
notify_send "SMB unmounted: $where"
;;
list)
@@ -141,12 +296,30 @@ case "$cmd" in
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
Active mount + automount units conflict; unmount first:
pos share smb-client unmount $where"
fi
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
opts="$(mount_opts "$user"),_netdev,noexec"
if [ -e "$unit_file" ] || [ -e "$auto_file" ]; then
warn "Units for $where already exist — they will be REPLACED:"
if [ -e "$unit_file" ]; then warn " ${unit_file}"; fi
if [ -e "$auto_file" ]; then warn " ${auto_file}"; fi
confirm "Replace them?" n || err "Aborted — units left untouched"
fi
opts="$(mount_opts),_netdev,noexec"
creds_file=""
if [ -n "$user" ]; then
creds_file="$SMB_CREDS_DIR/$(basename "$where")"
sudo mkdir -p "$SMB_CREDS_DIR"
@@ -159,7 +332,23 @@ case "$cmd" in
opts="guest,$opts"
fi
sudo mkdir -p "$where"
rollback_persist() {
warn "Rolling back everything this command created…"
sudo systemctl disable "$auto_unit" 2>/dev/null || true
sudo systemctl stop "$auto_unit" 2>/dev/null || true
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file" "$auto_file"
if [ -n "$creds_file" ]; then sudo rm -f "$creds_file"; fi
sudo systemctl daemon-reload 2>/dev/null || true
}
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) write $unit_file + $auto_file (Type=cifs, Options=$opts)"
log "(dry-run) daemon-reload + enable --now $auto_unit"
exit 0
fi
cat <<UNIT | sudo tee "$unit_file" >/dev/null
[Unit]
Description=SMB mount of ${what} at ${where}
@@ -183,8 +372,37 @@ Where=${where}
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now "$auto_unit"
# Enable, then prove the automount actually serves the share before
# declaring victory — a broken unit here would bite months later.
if ! out="$(sudo systemctl enable --now "$auto_unit" 2>&1)"; then
rollback_persist
err "Could not enable $auto_unit: $out"
fi
ls "$where" >/dev/null 2>&1 || true # poke the automount
verified=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [ -n "$(mounted_src "$where")" ]; then verified=1; break; fi
sleep 0.5
done
if [ "$verified" -ne 1 ]; then
rollback_persist
err "Automount did not trigger for $where — units removed again.
Check: systemctl status $auto_unit"
fi
# Same read-proof as one-shot mounts: catch permission problems now.
if ! timeout 5 ls -A "$where" >/dev/null 2>&1; then
rollback_persist
err "Automount triggered but $where is not readable — units + credentials removed.
Login works but file permissions don't — fix unix perms on the server
(the shared folder itself needs r-x for the connecting user).
Details: DOC/howto/share.md (SMB troubleshooting)"
fi
log "Persistent SMB mount (automount): ${what} → ${where} (${auto_unit})"
log "Verified: automount triggers and the share is readable"
notify_send "SMB mount persisted: ${what} → ${where}"
;;
@@ -199,7 +417,7 @@ UNIT
auto_file="${UNIT_DIR}/${auto_unit}"
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
warn "No systemd mount unit for $where (${unit})"
log "No persistent SMB mount for $where — nothing to do"
exit 0
fi
@@ -211,6 +429,11 @@ UNIT
sudo rm -f "$SMB_CREDS_DIR/$(basename "$where")"
sudo rmdir "$SMB_CREDS_DIR" 2>/dev/null || true
sudo systemctl daemon-reload
if [ -n "$(mounted_src "$where")" ]; then
warn "$where is still mounted (something holds it open)"
if confirm "Force a lazy unmount now?" y; then run sudo umount -l "$where"; fi
fi
log "Removed persistent SMB mount: $where"
notify_send "SMB persistent mount removed: $where"
;;
+58
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: share smb-server — Manage the Samba server (status, share/unshare exports, users, enable/disable)
# POS_SUBCMDS: status share unshare list adduser deluser reload enable disable
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
@@ -103,6 +104,33 @@ reload_config() {
fi
}
# rc 0 = Samba reachable through the firewall (or nothing to check),
# rc 1 = ufw active but no Samba rule → clients can't reach 139/445.
ufw_blocks_samba() {
command -v ufw >/dev/null 2>&1 || return 1
local st
st="$(sudo ufw status 2>/dev/null)" || return 1
grep -q "^Status: active" <<<"$st" || return 1
if grep -qiE 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)' <<<"$st"; then
return 1
fi
return 0
}
# Warn when another managed share already exports the same path under a
# different name (two names for one folder = confusing debug sessions).
dup_path_share() {
local path="$1" name="$2" dup
dup="$(awk -v want="$path" -v cur="$name" '
$1 == "#" && $2 == ">>>" { n = $5; inb = 1 }
$0 == "# <<< end pos-managed share" { inb = 0 }
inb && $1 == "path" && $3 == want && n != cur { print n; exit }
' "$SMB_CONF")"
[ -n "$dup" ] && warn "Path already shared as [$dup] — creating a second export of the same folder.
Prefer updating that one: pos share smb-server share $path $dup"
return 0
}
list_shares() {
local out
out="$(testparm -s "$SMB_CONF" 2>/dev/null | grep -F '[' || true)"
@@ -126,6 +154,22 @@ case "$cmd" in
echo
section "Users"
sudo pdbedit -L 2>/dev/null | cut -d: -f1 | sed 's/^/ /' || echo " (none)"
echo
section "Ports"
if ss -tln 2>/dev/null | awk '$4 ~ /:(139|445)$/ { f = 1 } END { exit !f }'; then
ok "smbd listening on :139/:445"
else
warn "nothing listening on :139/:445 locally (smbd down or bound elsewhere)"
fi
if command -v ufw >/dev/null 2>&1 && sudo ufw status 2>/dev/null | grep -q "^Status: active"; then
if ufw_blocks_samba; then
warn "firewall: ufw active, NO Samba rule — clients can't connect. Fix: sudo ufw allow Samba"
else
ok "firewall: ufw active, Samba allowed"
fi
else
ok "firewall: no active ufw — nothing blocking SMB ports"
fi
;;
share)
@@ -151,6 +195,7 @@ case "$cmd" in
done
name="${name:-$(basename "$path")}"
validate_share_name "$name"
dup_path_share "$path" "$name"
if [ "$guest" -eq 1 ]; then
warn "guest access on — ANY network user can access $path. Restrict with --users."
@@ -158,6 +203,10 @@ case "$cmd" in
warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2."
fi
[ -n "$users" ] && check_samba_users "$users"
if ufw_blocks_samba; then
warn "ufw is active but allows no Samba traffic — clients can't reach ports 139/445.
Fix on this machine: sudo ufw allow Samba"
fi
ro_val=no; [ "$ro" -eq 1 ] && ro_val=yes
guest_val=no; [ "$guest" -eq 1 ] && guest_val=yes
@@ -222,6 +271,11 @@ case "$cmd" in
user="${2:-}"
[ -n "$user" ] || err "Usage: pos share smb-server adduser <user>"
id -u "$user" >/dev/null 2>&1 || err "No system user '$user' — create it first (e.g. sudo adduser $user)"
if sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then
warn "'$user' already has a Samba account — nothing to do."
log "Reset the password with: sudo smbpasswd $user"
exit 0
fi
sudo smbpasswd -a "$user"
log "Samba user added: $user"
notify_send "SMB user added: $user"
@@ -230,6 +284,10 @@ case "$cmd" in
deluser)
user="${2:-}"
[ -n "$user" ] || err "Usage: pos share smb-server deluser <user>"
if ! sudo pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qxF "$user"; then
log "'$user' is not a Samba user — nothing to remove"
exit 0
fi
sudo smbpasswd -x "$user"
log "Samba user removed: $user"
notify_send "SMB user removed: $user"