From a0152fa87c6057ede968184536b674148138eb97 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 21 Aug 2026 09:53:10 -0400 Subject: [PATCH] . --- .n | 1 + AGENTS.md | 5 +- AGENT_TODO.md | 1 + bin/pos-share-smb-client | 255 ++++++++++++++++++++++++++++++++++++--- bin/pos-share-smb-server | 58 +++++++++ 5 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 .n diff --git a/.n b/.n new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/.n @@ -0,0 +1 @@ +1 diff --git a/AGENTS.md b/AGENTS.md index b981f3a..fc5bb63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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--`, or **category-less** `bin/pos-` 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: ` 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/` or `ci-fail/` (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 || 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/.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: ` + `# 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 --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--`, 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. diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 712e926..d11d089 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -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/`/`ci-fail/` 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. diff --git a/bin/pos-share-smb-client b/bin/pos-share-smb-client index a15288d..6079059 100755 --- a/bin/pos-share-smb-client +++ b/bin/pos-share-smb-client @@ -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 [user] One-shot mount (creates local-dir if needed) - unmount Unmount the share + unmount Unmount the share (idempotent) list Show active + persistent SMB mounts persist [user] Persistent mount via systemd .mount + .automount units (mounts on first access — never blocks boot) unpersist 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 (e.g. //10.0.0.5/media)" ;; *) err "Invalid share '$1' — expected (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 &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 + - 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 + 2. user missing from the Samba passdb: pos share smb-server adduser + 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/" ;; + *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 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 [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 " 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 [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 </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" ;; diff --git a/bin/pos-share-smb-server b/bin/pos-share-smb-server index 610eaff..92fa166 100755 --- a/bin/pos-share-smb-server +++ b/bin/pos-share-smb-server @@ -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 " 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 " + 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"