@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user