feat: share suite — interactive menus for usb/nfs/smb server+client on share-lib domain layer

This commit is contained in:
Your Name
2026-08-24 14:44:18 -04:00
parent f61766b074
commit 5b2a0304e1
10 changed files with 1558 additions and 486 deletions
+2 -2
View File
@@ -16,9 +16,9 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
- **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`, 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).
- **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 — query with `scripts/ci-status.sh [--wait] [<sha>]`; exit 0 green / 1 red / 2 pending). 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.
- **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. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt 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 *`).
+28
View File
@@ -55,6 +55,12 @@ omitted.
- **Dedicated USB-over-network box:** set `--port` once, then clients connect
to that port.
**Interactive menu:** run `pos share usb server` with no args for a menu
(list / share / unshare / auto-share / disconnect …). The share flow lists
devices and clients from the server as pickers — no IDs to memorize; if the
server listing can't be read, it prints the raw output and falls back to
manual ID entry.
**Troubleshooting:**
- `usbsrv: command not found` → the binary isn't installed; get it from
incentivespro.com and drop it in `x64_bin/` (or `arm64_bin/`) then re-run
@@ -101,6 +107,12 @@ to restrict it** — print the restricted form:
- **Read-only backups to a LAN host:** use `(ro,sync,no_subtree_check)` and only
`enable` the server where it's needed.
**Interactive menu:** run `pos share nfs server` with no args for a menu
(share / unshare / list / reload / enable / disable / status). The share flow
offers mounted folders as a picker and client-spec presets (open, WireGuard,
LAN, single IP) so you don't hand-type export specs; an inactive
`nfs-server` service or a UFW conflict is offered as a one-key fix.
**Troubleshooting:**
- "exportfs not found" → `nfs-kernel-server` isn't installed; `sudo apt install nfs-kernel-server`
- Client sees "mount.nfs: Permission denied" → your `/etc/exports` client rule
@@ -139,6 +151,11 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
- **One-off mount (no persistence):**
`pos share nfs client mount 10.0.0.5:/srv/data /mnt/data`
**Interactive menu:** run `pos share nfs client` with no args for a menu
(mount / persist / unmount / unpersist / list). Mountpoints are offered from
existing mount-layout candidates with manual entry as fallback; unmount and
unpersist tolerate already-absent targets instead of erroring.
**Troubleshooting:**
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common`
- Mount hangs → check the server export (`pos share nfs server list` on the
@@ -197,6 +214,11 @@ Both are warnings only — the share is still written.
- **Change a share's access later:** re-run `share` with the same name — the
block is replaced, not duplicated.
**Interactive menu:** run `pos share smb server` with no args for a menu
(share / unshare / list / users / reload / enable / disable / status). The
share flow offers mounted folders as a picker and walks through read-only /
guest / valid-users confirms; a UFW conflict is offered as a one-key fix.
**Troubleshooting:**
- "smbd not found" → `samba` isn't installed; `sudo apt install samba`
- Windows can't connect → check the client is in `--users` / has a Samba
@@ -259,6 +281,12 @@ fstab it could). `enable --now` arms the automount immediately.
persistent units under "Persistent (automount)", so the configured shares are
visible even before their first access
**Interactive menu:** run `pos share smb client` with no args for a menu
(enumerate / mount / persist / unmount / unpersist / list). Enter the server,
an empty user tries guest enumeration first (with an auth retry on denial),
then shares and mountpoints are offered as pickers with manual fallback —
the account you authenticated with is reused for the mount.
---
## Related
+1 -1
View File
@@ -259,7 +259,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
# Commands that read from stdin interactively — only log invocation
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
+273 -68
View File
@@ -1,9 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: share nfs-client — Mount NFS shares (ephemeral or persistent systemd mount units)
# POS_SUBCMDS: mount unmount list persist unpersist menu
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"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
# Env seam (testable): where persistent .mount units are written.
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
command -v mount.nfs &>/dev/null || err "mount.nfs not found (install nfs-common)"
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
@@ -21,24 +26,20 @@ Commands:
persist <server:export> <local-dir> Persistent mount via a systemd .mount unit
(ordered after network-online.target)
unpersist <local-dir> Stop, disable and remove the mount unit
menu Interactive browser (server → export → mountpoint)
Run without arguments to open the interactive menu.
Examples:
pos share nfs-client mount 100.100.100.1:/srv/media /mnt/nfs/media
pos share nfs-client persist 100.100.100.1:/srv/media /mnt/nfs/media
pos share nfs-client list
pos share nfs-client unmount /mnt/nfs/media
pos share nfs-client menu
EOF
exit 0
}
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
mount|unmount|list|persist|unpersist) ;;
"") err "Missing command (mount|unmount|list|persist|unpersist)" ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
validate_share() {
local what="$1"
case "$what" in
@@ -56,83 +57,287 @@ validate_dir() {
esac
}
case "$cmd" in
mount)
what="${2:-}"
where="${3:-}"
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client mount <server:export> <local-dir>"
validate_share "$what"
validate_dir "$where"
cmd_mount() {
local what="$1" where="$2"
validate_share "$what"
validate_dir "$where"
sudo mkdir -p "$where"
sudo mount -t nfs -o rw,noatime "$what" "$where"
log "Mounted $what at $where"
;;
sudo mkdir -p "$where"
sudo mount -t nfs -o rw,noatime "$what" "$where"
log "Mounted $what at $where"
}
unmount)
where="${2:-}"
[ -n "$where" ] || err "Usage: pos share nfs-client unmount <local-dir>"
validate_dir "$where"
cmd_unmount() {
local where="$1"
validate_dir "$where"
sudo umount "$where"
log "Unmounted $where"
;;
if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
log "$where is not mounted as NFS — nothing to do"
return 0
fi
sudo umount "$where"
log "Unmounted $where"
}
list)
if findmnt -t nfs,nfs4 >/dev/null 2>&1; then
findmnt -t nfs,nfs4
else
echo "No NFS mounts"
fi
;;
cmd_list() {
local out
if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then
printf '%s\n' "$out"
else
echo "No NFS mounts"
fi
}
persist)
what="${2:-}"
where="${3:-}"
[ -n "$what" ] && [ -n "$where" ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
validate_share "$what"
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
unit_file="/etc/systemd/system/${unit}"
sudo mkdir -p "$where"
cat <<UNIT | sudo tee "$unit_file" >/dev/null
write_mount_unit() { # <unit_file> <what> <where>
cat <<UNIT | sudo tee "$1" >/dev/null
[Unit]
Description=NFS mount of ${what} at ${where}
Description=NFS mount of ${2} at ${3}
After=network-online.target
Wants=network-online.target
[Mount]
What=${what}
Where=${where}
What=${2}
Where=${3}
Type=nfs
Options=defaults,_netdev,rw,noatime
UNIT
}
show_mount_unit() { # <what> <where> (dry-run preview)
cat <<UNIT
[Unit]
Description=NFS mount of ${1} at ${2}
After=network-online.target
Wants=network-online.target
[Mount]
What=${1}
Where=${2}
Type=nfs
Options=defaults,_netdev,rw,noatime
UNIT
}
cmd_persist() {
local what="$1" where="$2"
validate_share "$what"
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
unit_file="${UNIT_DIR}/${unit}"
if [ -f "$unit_file" ]; then
if [ -t 0 ]; then
# FLAGGED DELTA: interactive overwrite now confirms first.
confirm "Unit ${unit} already exists — overwrite?" n ||
{ warn "Aborted — ${unit_file} left untouched"; return 1; }
else
warn "Overwriting existing unit ${unit}"
fi
fi
sudo mkdir -p "$where"
if [ "${DRY_RUN:-0}" -eq 1 ]; then
log "(dry-run) would write ${unit_file}:"
show_mount_unit "$what" "$where"
else
write_mount_unit "$unit_file" "$what" "$where"
sudo systemctl daemon-reload
sudo systemctl enable --now "$unit"
log "Persistent NFS mount: ${what} → ${where} (${unit})"
notify_send "NFS mount persisted: ${what} → ${where}"
# Verify the export actually mounted; roll back the unit if not.
local i mounted=1
for i in 1 2 3 4 5; do
if findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
mounted=0
break
fi
sleep 1
done
if [ "$mounted" -ne 0 ]; then
warn "Unit enabled but ${where} never appeared among NFS mounts — rolling back"
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file"
sudo systemctl daemon-reload
err "Persistent mount failed — unit removed (${unit})"
fi
fi
log "Persistent NFS mount: ${what} → ${where} (${unit})"
notify_send "NFS mount persisted: ${what} → ${where}"
}
cmd_unpersist() {
local where="$1"
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
unit_file="${UNIT_DIR}/${unit}"
if [ ! -f "$unit_file" ]; then
warn "No systemd mount unit for $where (${unit})"
exit 0
fi
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file"
sudo systemctl daemon-reload
log "Removed persistent NFS mount: $where"
notify_send "NFS persistent mount removed: $where"
}
# ── Interactive menu flows ─────────────────────────────────────
menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
local host="$1" idx exp
local -a exports=()
if mapfile -t exports < <(share_nfs_exports "$host") && [ "${#exports[@]}" -gt 0 ]; then
if idx="$(share_pick "Pick export on ${host}" "${exports[@]}")"; then
exp="${exports[$((idx - 1))]}"
else
return 1
fi
else
exp="$(share_ask_value "Export path on ${host} (e.g. /srv/media)")" || return 1
[ -n "$exp" ] || { warn "No export path given"; return 1; }
fi
case "$exp" in
/*) echo "${host}:${exp}" ;;
*) echo "${host}:/${exp}" ;;
esac
}
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint"
return 1
;;
*)
echo "$dir"
return 0
;;
esac
fi
fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
}
menu_mount() {
local mode="$1" host what where
host="$(share_ask_value "NFS server (host or IP)")" || return 1
[ -n "$host" ] || { warn "No server given"; return 1; }
if ! share_port_probe "$host" 2049; then
warn "${host} does not answer on TCP/2049 (nfsd down, or a firewall blocks it)."
confirm "Try anyway?" n || return 1
fi
what="$(menu_pick_export "$host")" || return 1
where="$(menu_ask_mountpoint)" || return 1
if [ "$mode" = "persist" ]; then
cmd_persist "$what" "$where"
else
cmd_mount "$what" "$where"
fi
}
menu_unmount() {
local idx where
local -a targets=()
if mapfile -t targets < <(findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | tail -n +2) &&
[ "${#targets[@]}" -gt 0 ]; then
idx="$(share_pick "Unmount which NFS mount?" "${targets[@]}")" || return 1
where="${targets[$((idx - 1))]}"
else
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
[ -n "$where" ] || return 1
fi
cmd_unmount "$where"
}
menu_unpersist() {
local idx uf where unit
local -a items=() paths=()
for uf in "${UNIT_DIR}"/*.mount; do
grep -q '^Type=nfs' "$uf" 2>/dev/null || continue
where="$(sed -n 's/^Where=//p' "$uf")"
[ -n "$where" ] || continue
paths+=("$where")
items+=("$where")
done
if [ "${#items[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which persistent NFS mount?" "${items[@]}")" || return 1
where="${paths[$((idx - 1))]}"
else
where="$(share_ask_value "Local mountpoint whose unit to remove")" || return 1
[ -n "$where" ] || return 1
fi
cmd_unpersist "$where"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "NFS client" \
"Mount an export (one-shot)" \
"Persist an export (systemd .mount unit)" \
"List active NFS mounts" \
"Unmount a mounted share" \
"Remove a persistent mount")" || return 0
case "$choice" in
1) menu_mount ephemeral ;;
2) menu_mount persist ;;
3) cmd_list ;;
4) menu_unmount ;;
5) menu_unpersist ;;
esac
done
}
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
""|menu)
run_menu
exit 0
;;
mount|unmount|list|persist|unpersist) ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
case "$cmd" in
mount)
[ $# -ge 3 ] || err "Usage: pos share nfs-client mount <server:export> <local-dir>"
cmd_mount "$2" "$3"
;;
unmount)
[ $# -ge 2 ] || err "Usage: pos share nfs-client unmount <local-dir>"
cmd_unmount "$2"
;;
list)
cmd_list
;;
persist)
[ $# -ge 3 ] || err "Usage: pos share nfs-client persist <server:export> <local-dir>"
cmd_persist "$2" "$3"
;;
unpersist)
where="${2:-}"
[ -n "$where" ] || err "Usage: pos share nfs-client unpersist <local-dir>"
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
unit_file="/etc/systemd/system/${unit}"
if [ ! -f "$unit_file" ]; then
warn "No systemd mount unit for $where (${unit})"
exit 0
fi
sudo systemctl disable "$unit" 2>/dev/null || true
sudo systemctl stop "$unit" 2>/dev/null || true
sudo rm -f "$unit_file"
sudo systemctl daemon-reload
log "Removed persistent NFS mount: $where"
notify_send "NFS persistent mount removed: $where"
[ $# -ge 2 ] || err "Usage: pos share nfs-client unpersist <local-dir>"
cmd_unpersist "$2"
;;
esac
+184 -73
View File
@@ -1,12 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: share nfs-server — Manage the NFS kernel server (status, share/unshare exports, enable/disable)
# POS_SUBCMDS: status share unshare list reload enable disable menu
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"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
command -v exportfs &>/dev/null || err "exportfs not found (install nfs-kernel-server)"
# Env seam (testable): which exports file is managed.
EXPORTS_FILE="${EXPORTS_FILE:-/etc/exports}"
usage() {
cat <<EOF
Usage: pos share nfs-server <command> [args]
@@ -22,6 +27,9 @@ Commands:
reload Re-apply /etc/exports after hand edits
enable Start nfs-server and enable it on boot
disable Stop nfs-server and disable it on boot
menu Interactive browser (folder → client preset → share)
Run without arguments to open the interactive menu.
Clients are /etc/exports entries — restrict them to your trusted network:
pos share nfs-server share /mnt/hdd '100.64.0.0/10(rw,sync,no_subtree_check)' # Tailscale CGNAT
@@ -33,6 +41,7 @@ Examples:
pos share nfs-server share /mnt/hdd
pos share nfs-server list
pos share nfs-server unshare /mnt/hdd
pos share nfs-server menu
EOF
exit 0
}
@@ -40,13 +49,10 @@ EOF
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
status|share|unshare|list|reload|enable|disable) ;;
"") err "Missing command (status|share|unshare|list|reload|enable|disable)" ;;
""|menu|status|share|unshare|list|reload|enable|disable) ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
EXPORTS_FILE=/etc/exports
require_root_dir() {
local path="$1"
case "$path" in
@@ -56,79 +62,184 @@ require_root_dir() {
[ -d "$path" ] || err "Path not found: $path"
}
case "$cmd" in
status)
if systemctl is-active --quiet nfs-server 2>/dev/null; then
ok "nfs-server: running"
else
warn "nfs-server: not running (enable with 'pos share nfs-server enable')"
fi
cmd_status() {
if systemctl is-active --quiet nfs-server 2>/dev/null; then
ok "nfs-server: running"
else
warn "nfs-server: not running (enable with 'pos share nfs-server enable')"
fi
echo
section "Exports"
exportfs -v 2>/dev/null || echo " (none)"
}
cmd_share() {
local path="$1" client="${2:-*(rw,sync,no_subtree_check)}"
require_root_dir "$path"
if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then
warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:"
echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'"
echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'"
echo
section "Exports"
exportfs -v 2>/dev/null || echo " (none)"
;;
fi
local tmp
tmp="$(mktemp)"
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
echo "$path $client" >> "$tmp"
sudo cp "$tmp" "$EXPORTS_FILE"
rm -f "$tmp"
sudo exportfs -ra
log "Exported: $path $client"
notify_send "NFS share added: $path $client"
# Advisory post-checks (never abort the share operation).
if ! share_service_active nfs-server; then
share_offer_fix "The nfs-server service is not running" \
sudo systemctl enable --now nfs-server
fi
if share_ufw_blocks_ports '2049|111|\bnfs\b'; then
warn "ufw is active but has no NFS rule — clients will be blocked."
share_offer_fix "Allow NFS through ufw" sudo ufw allow 2049/tcp
fi
}
cmd_unshare() {
local path="$1"
require_root_dir "$path"
if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then
warn "No export for $path in $EXPORTS_FILE"
exit 0
fi
local tmp
tmp="$(mktemp)"
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
sudo cp "$tmp" "$EXPORTS_FILE"
rm -f "$tmp"
sudo exportfs -ra
log "Removed export: $path"
notify_send "NFS share removed: $path"
}
cmd_list() {
exportfs -v 2>/dev/null || echo "No exports"
}
cmd_reload() {
sudo exportfs -ra
log "NFS exports reloaded"
}
cmd_enable() {
sudo systemctl enable --now nfs-server
log "nfs-server enabled (starts on boot)"
notify_send "NFS server enabled"
}
cmd_disable() {
sudo systemctl disable --now nfs-server
log "nfs-server disabled (will not start on boot)"
notify_send "NFS server disabled"
}
# ── Interactive menu flows ─────────────────────────────────────
menu_pick_folder() { # stdout: folder path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
[ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; }
echo "$dir"
return 0
fi
return 1
fi
dir="$(share_ask_value "Folder to share (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No folder given"; return 1; }
echo "$dir"
}
menu_share() {
local dir idx spec
dir="$(menu_pick_folder)" || return 1
local -a specs=(
"100.64.0.0/10(rw,sync,no_subtree_check) — Tailscale CGNAT range"
"10.10.0.0/24(rw,sync,no_subtree_check) — WireGuard subnet"
"192.168.1.0/24(rw,sync,no_subtree_check) — LAN subnet"
"192.168.1.0/24(ro,sync,no_subtree_check) — LAN read-only"
"* (rw,sync,no_subtree_check) — ANY client (unsafe)"
)
if idx="$(share_pick "Client access for ${dir}" "${specs[@]}")"; then
spec="$(sed 's/[[:space:]]*—.*//' <<<"${specs[$((idx - 1))]}")"
[ "$spec" = "*" ] && spec="*(rw,sync,no_subtree_check)"
else
spec="$(share_ask_value "Client spec (e.g. 10.10.0.0/24(rw,sync))" "")" || return 1
[ -n "$spec" ] || spec="*(rw,sync,no_subtree_check)"
fi
cmd_share "$dir" "$spec"
}
menu_unshare() {
local idx path
local -a paths=()
if mapfile -t paths < <(awk 'NF > 0 && $1 !~ /^#/ {print $1}' "$EXPORTS_FILE" 2>/dev/null) &&
[ "${#paths[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which export?" "${paths[@]}")" || return 1
path="${paths[$((idx - 1))]}"
else
path="$(share_ask_value "Exported path to remove")" || return 1
[ -n "$path" ] || return 1
fi
cmd_unshare "$path"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "NFS server" \
"Show status (service + exports)" \
"Share a folder" \
"Remove an export" \
"List current exports" \
"Reload exports after hand edits" \
"Enable service on boot" \
"Disable service")" || return 0
case "$choice" in
1) cmd_status ;;
2) menu_share ;;
3) menu_unshare ;;
4) cmd_list ;;
5) cmd_reload ;;
6) cmd_enable ;;
7) cmd_disable ;;
esac
done
}
case "$cmd" in
""|menu)
run_menu
exit 0
;;
status) cmd_status ;;
share)
path="${2:-}"
client="${3:-*(rw,sync,no_subtree_check)}"
[ -n "$path" ] || err "Usage: pos share nfs-server share <path> [client]"
require_root_dir "$path"
if [ "$client" = "*(rw,sync,no_subtree_check)" ]; then
warn "Generic export '$client' — ANY client can mount $path. Restrict it, e.g.:"
echo " pos share nfs-server share $path '100.64.0.0/10(rw,sync,no_subtree_check)'"
echo " pos share nfs-server share $path '10.10.0.0/24(rw,sync,no_subtree_check)'"
echo
fi
tmp="$(mktemp)"
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
echo "$path $client" >> "$tmp"
sudo cp "$tmp" "$EXPORTS_FILE"
rm -f "$tmp"
sudo exportfs -ra
log "Exported: $path $client"
notify_send "NFS share added: $path $client"
[ $# -ge 2 ] || err "Usage: pos share nfs-server share <path> [client]"
cmd_share "$2" "${3:-*(rw,sync,no_subtree_check)}"
;;
unshare)
path="${2:-}"
[ -n "$path" ] || err "Usage: pos share nfs-server unshare <path>"
require_root_dir "$path"
if ! awk -v p="$path" '$1 == p {found=1} END {exit !found}' "$EXPORTS_FILE"; then
warn "No export for $path in $EXPORTS_FILE"
exit 0
fi
tmp="$(mktemp)"
awk -v p="$path" '$1 != p' "$EXPORTS_FILE" > "$tmp"
sudo cp "$tmp" "$EXPORTS_FILE"
rm -f "$tmp"
sudo exportfs -ra
log "Removed export: $path"
notify_send "NFS share removed: $path"
;;
list)
exportfs -v 2>/dev/null || echo "No exports"
;;
reload)
sudo exportfs -ra
log "NFS exports reloaded"
;;
enable)
sudo systemctl enable --now nfs-server
log "nfs-server enabled (starts on boot)"
notify_send "NFS server enabled"
;;
disable)
sudo systemctl disable --now nfs-server
log "nfs-server disabled (will not start on boot)"
notify_send "NFS server disabled"
[ $# -ge 2 ] || err "Usage: pos share nfs-server unshare <path>"
cmd_unshare "$2"
;;
list) cmd_list ;;
reload) cmd_reload ;;
enable) cmd_enable ;;
disable) cmd_disable ;;
esac
+320 -184
View File
@@ -1,10 +1,11 @@
#!/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
# POS_SUBCMDS: mount unmount list persist unpersist menu
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"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
@@ -53,8 +54,7 @@ EOF
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
mount|unmount|list|persist|unpersist) ;;
"") err "Missing command (mount|unmount|list|persist|unpersist)" ;;
""|menu|mount|unmount|list|persist|unpersist) ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
@@ -200,156 +200,150 @@ read_test_or_rollback() {
Details: DOC/howto/share.md (SMB troubleshooting)"
}
case "$cmd" in
mount)
what="${2:-}"
where="${3:-}"
user="${4:-}"
[ -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"
cmd_mount() { # <//server/share> <local-dir> [user]
local what="$1" where="$2" user="${3:-}"
local src creds opts
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
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
fi
ensure_mountpoint "$where"
ensure_mountpoint "$where"
if [ -n "$user" ]; then
creds="$(make_creds "$user")"
trap 'rm -f "$creds"' EXIT
opts="credentials=$creds,$(mount_opts)"
try_mount "$what" "$where" "$opts" || {
rm -f "$creds"
trap - EXIT
diagnose_mount_failure "${MOUNT_ERR:-}"
}
if [ -n "$user" ]; then
creds="$(make_creds "$user")"
trap 'rm -f "$creds"' EXIT
opts="credentials=$creds,$(mount_opts)"
try_mount "$what" "$where" "$opts" || {
rm -f "$creds"
trap - EXIT
else
warn "No user — attempting guest mount (works only if the server allows guest access)"
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
fi
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)"
try_mount "$what" "$where" "guest,$(mount_opts)" || diagnose_mount_failure "${MOUNT_ERR:-}"
fi
read_test_or_rollback "$where" "$what"
log "Mounted $what at $where (read test passed)"
notify_send "SMB mounted: $what → $where"
;;
read_test_or_rollback "$where" "$what"
log "Mounted $what at $where (read test passed)"
notify_send "SMB mounted: $what → $where"
}
unmount)
where="${2:-}"
[ -n "$where" ] || err "Usage: pos share smb-client unmount <local-dir>"
validate_dir "$where"
cmd_unmount() { # <local-dir>
local where="$1" src out
validate_dir "$where"
src="$(mounted_src "$where")"
if [ -z "$src" ]; then
log "Nothing mounted at $where"
exit 0
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
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"
;;
err "Unmount failed: $out"
fi
log "Unmounted $where"
notify_send "SMB unmounted: $where"
}
list)
found=0
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
if [ -n "$active_mounts" ]; then
printf 'Active mounts:\n'
printf '%s\n' "$active_mounts"
found=1
fi
persistent=()
for unit in "${UNIT_DIR}"/*.mount; do
[ -e "$unit" ] || continue
grep -q '^Type=cifs$' "$unit" || continue
what="$(sed -n 's/^What=//p' "$unit")"
where="$(sed -n 's/^Where=//p' "$unit")"
[ -n "$what" ] && [ -n "$where" ] || continue
persistent+=("$where|$what")
cmd_list() {
local found=0 active_mounts unit what where
local -a persistent=()
active_mounts="$(findmnt -t cifs 2>/dev/null || true)"
if [ -n "$active_mounts" ]; then
printf 'Active mounts:\n'
printf '%s\n' "$active_mounts"
found=1
fi
for unit in "${UNIT_DIR}"/*.mount; do
[ -e "$unit" ] || continue
grep -q '^Type=cifs$' "$unit" || continue
what="$(sed -n 's/^What=//p' "$unit")"
where="$(sed -n 's/^Where=//p' "$unit")"
[ -n "$what" ] && [ -n "$where" ] || continue
persistent+=("$where|$what")
done
if [ "${#persistent[@]}" -gt 0 ]; then
found=1
printf 'Persistent (automount):\n'
for entry in "${persistent[@]}"; do
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
done
if [ "${#persistent[@]}" -gt 0 ]; then
found=1
printf 'Persistent (automount):\n'
for entry in "${persistent[@]}"; do
printf ' %-44s %s\n' "${entry%%|*}" "${entry#*|}"
done
fi
[ "$found" -eq 1 ] || echo "No SMB mounts"
;;
fi
[ "$found" -eq 1 ] || echo "No SMB mounts"
}
persist)
what="${2:-}"
where="${3:-}"
user="${4:-}"
[ -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"
cmd_persist() { # <//server/share> <local-dir> [user]
local what="$1" where="$2" user="${3:-}"
local src unit auto_unit unit_file auto_file opts creds_file tmp out verified
validate_share "$what"
validate_dir "$where"
split_share "$what"
probe_server "$SERVER"
probe_server "$SERVER"
if src="$(mounted_src "$where")"; [ -n "$src" ]; then
err "$where is already mounted (source: ${src:-unknown}) — nothing done.
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
fi
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
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
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"
tmp="$(make_creds "$user")"
sudo install -m 600 "$tmp" "$creds_file"
rm -f "$tmp"
opts="credentials=$creds_file,$opts"
else
warn "No user — persisting a guest mount (works only if the server allows guest access)"
opts="guest,$opts"
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"
tmp="$(make_creds "$user")"
sudo install -m 600 "$tmp" "$creds_file"
rm -f "$tmp"
opts="credentials=$creds_file,$opts"
else
warn "No user — persisting a guest mount (works only if the server allows guest access)"
opts="guest,$opts"
fi
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
}
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
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
cat <<UNIT | sudo tee "$unit_file" >/dev/null
[Unit]
Description=SMB mount of ${what} at ${where}
After=network-online.target
@@ -361,7 +355,7 @@ Where=${where}
Type=cifs
Options=${opts}
UNIT
cat <<UNIT | sudo tee "$auto_file" >/dev/null
cat <<UNIT | sudo tee "$auto_file" >/dev/null
[Unit]
Description=Automount of SMB share ${what} at ${where}
@@ -371,70 +365,212 @@ Where=${where}
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl daemon-reload
# 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
# 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.
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
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.
# 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
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}"
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}"
}
cmd_unpersist() { # <local-dir>
local where="$1" unit auto_unit unit_file auto_file
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
log "No persistent SMB mount for $where — nothing to do"
exit 0
fi
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"
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"
}
# ── Interactive menu flows ─────────────────────────────────────
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint"
return 1
;;
*)
echo "$dir"
return 0
;;
esac
fi
fi
dir="$(share_ask_value "Mountpoint (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No mountpoint given"; return 1; }
echo "$dir"
}
menu_mount() { # ephemeral|persist
local mode="$1" host idx share what where
host="$(share_ask_value "SMB server (host or IP)")" || return 1
[ -n "$host" ] || { warn "No server given"; return 1; }
# Empty answer = guest enumeration (with an interactive auth retry inside
# the helper); a named user authenticates right away. SMB_AUTH_USER tells
# us which account ended up being used so the mount reuses it.
local user=""
read -rp "Samba user (empty = try guest): " user || return 1
SMB_AUTH_USER=""
local -a shares=()
if mapfile -t shares < <(share_smb_shares "$host" "$user") && [ "${#shares[@]}" -gt 0 ]; then
idx="$(share_pick "Pick share on ${host}" "${shares[@]}")" || return 1
share="${shares[$((idx - 1))]}"
else
share="$(share_ask_value "Share name on ${host} (e.g. media)")" || return 1
[ -n "$share" ] || { warn "No share name given"; return 1; }
fi
user="${SMB_AUTH_USER:-$user}"
what="//${host}/${share}"
where="$(menu_ask_mountpoint)" || return 1
if [ "$mode" = "persist" ]; then
cmd_persist "$what" "$where" "$user"
else
cmd_mount "$what" "$where" "$user"
fi
}
menu_unmount() {
local idx row where
local -a targets=()
if mapfile -t rows < <(findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null) &&
[ "${#rows[@]}" -gt 0 ]; then
for row in "${rows[@]}"; do
targets+=("${row##* }") # last field of raw mode = TARGET
done
idx="$(share_pick "Unmount which SMB mount?" "${targets[@]}")" || return 1
where="${targets[$((idx - 1))]}"
else
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
[ -n "$where" ] || return 1
fi
cmd_unmount "$where"
}
menu_unpersist() {
local idx uf where unit_w
local -a paths=()
for uf in "${UNIT_DIR}"/*.mount; do
grep -q '^Type=cifs$' "$uf" 2>/dev/null || continue
unit_w="$(sed -n 's/^Where=//p' "$uf")"
[ -n "$unit_w" ] || continue
paths+=("$unit_w")
done
if [ "${#paths[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which persistent SMB mount?" "${paths[@]}")" || return 1
where="${paths[$((idx - 1))]}"
else
where="$(share_ask_value "Local mountpoint whose units to remove")" || return 1
[ -n "$where" ] || return 1
fi
cmd_unpersist "$where"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "SMB client" \
"Mount a share (one-shot)" \
"Persist a share (automount units)" \
"List active + persistent mounts" \
"Unmount a mounted share" \
"Remove a persistent mount")" || return 0
case "$choice" in
1) menu_mount ephemeral ;;
2) menu_mount persist ;;
3) cmd_list ;;
4) menu_unmount ;;
5) menu_unpersist ;;
esac
done
}
case "$cmd" in
""|menu)
run_menu
exit 0
;;
mount)
[ $# -ge 3 ] || err "Usage: pos share smb-client mount <//server/share> <local-dir> [user]"
cmd_mount "$2" "$3" "${4:-}"
;;
unmount)
[ $# -ge 2 ] || err "Usage: pos share smb-client unmount <local-dir>"
cmd_unmount "$2"
;;
list)
cmd_list
;;
persist)
[ $# -ge 3 ] || err "Usage: pos share smb-client persist <//server/share> <local-dir> [user]"
cmd_persist "$2" "$3" "${4:-}"
;;
unpersist)
where="${2:-}"
[ -n "$where" ] || err "Usage: pos share smb-client unpersist <local-dir>"
validate_dir "$where"
unit="$(systemd-escape --path --suffix=mount "$where")"
auto_unit="${unit%.mount}.automount"
unit_file="${UNIT_DIR}/${unit}"
auto_file="${UNIT_DIR}/${auto_unit}"
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
log "No persistent SMB mount for $where — nothing to do"
exit 0
fi
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"
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"
[ $# -ge 2 ] || err "Usage: pos share smb-client unpersist <local-dir>"
cmd_unpersist "$2"
;;
esac
+284 -154
View File
@@ -1,10 +1,11 @@
#!/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
# POS_SUBCMDS: status share unshare list adduser deluser reload enable disable menu
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"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
command -v smbd &>/dev/null || err "smbd not found (install samba)"
command -v smbcontrol &>/dev/null || err "smbcontrol not found (install samba)"
@@ -48,8 +49,7 @@ SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
cmd="${1:-}"
case "$cmd" in
-h|--help) usage ;;
status|share|unshare|list|adduser|deluser|reload|enable|disable) ;;
"") err "Missing command (status|share|unshare|list|adduser|deluser|reload|enable|disable)" ;;
""|menu|status|share|unshare|list|adduser|deluser|reload|enable|disable) ;;
*) err "Unknown command '$cmd' (see --help)" ;;
esac
@@ -106,15 +106,9 @@ reload_config() {
# 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.
# Thin wrapper: the decision core lives in lib/share-lib.sh.
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
share_ufw_blocks_ports 'samba|(^|[^0-9])(137|138|139|445)([^0-9]|$)'
}
# Warn when another managed share already exports the same path under a
@@ -141,171 +135,307 @@ list_shares() {
printf '%s\n' "$out" | grep -Fv '[global]' | sed 's/\[\(.*\)\]/ \1/'
}
case "$cmd" in
status)
if systemctl is-active --quiet smbd 2>/dev/null; then
ok "smbd: running"
else
warn "smbd: not running (enable with 'pos share smb-server enable')"
fi
echo
section "Shares"
list_shares
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)
path="${2:-}"
[ -n "$path" ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
case "$path" in
-*) err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]" ;;
esac
require_root_dir "$path"
check_traversal "$path"
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
shift 2
name="" ro=0 guest=0 users=""
while [ $# -gt 0 ]; do
case "$1" in
--read-only) ro=1; shift ;;
--guest) guest=1; shift ;;
--users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;;
-*) err "unknown option '$1'" ;;
*) name="$1"; shift ;;
esac
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."
elif [ -z "$users" ]; then
warn "No valid users — any Samba account can access $path. Restrict with --users u1,u2."
fi
[ -n "$users" ] && check_samba_users "$users"
cmd_status() {
if systemctl is-active --quiet smbd 2>/dev/null; then
ok "smbd: running"
else
warn "smbd: not running (enable with 'pos share smb-server enable')"
fi
echo
section "Shares"
list_shares
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 "ufw is active but allows no Samba traffic — clients can't reach ports 139/445.
Fix on this machine: sudo ufw allow Samba"
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
}
ro_val=no; [ "$ro" -eq 1 ] && ro_val=yes
guest_val=no; [ "$guest" -eq 1 ] && guest_val=yes
block="[$name]
cmd_share() {
local path="$1"
[ -n "$path" ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
case "$path" in
-*) err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]" ;;
esac
require_root_dir "$path"
check_traversal "$path"
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
shift
local name="" ro=0 guest=0 users=""
while [ $# -gt 0 ]; do
case "$1" in
--read-only) ro=1; shift ;;
--guest) guest=1; shift ;;
--users) [ $# -ge 2 ] || err "--users needs a value"; users="$2"; shift 2 ;;
-*) err "unknown option '$1'" ;;
*) name="$1"; shift ;;
esac
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."
elif [ -z "$users" ]; then
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
local ro_val=no guest_val=no block tmp
[ "$ro" -eq 1 ] && ro_val=yes
[ "$guest" -eq 1 ] && guest_val=yes
block="[$name]
path = $path
browseable = yes
read only = $ro_val
guest ok = $guest_val"
[ -n "$users" ] && block="$block
[ -n "$users" ] && block="$block
valid users = $users"
tmp="$(mktemp)"
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
$0 == s {inblock=1}
$0 == e && inblock == 1 {inblock=0; next}
!inblock {print}
' "$SMB_CONF" > "$tmp"
{
echo
echo "# >>> pos-managed share: $name"
printf '%s\n' "$block"
echo "# <<< end pos-managed share"
} >> "$tmp"
tmp="$(mktemp)"
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
$0 == s {inblock=1}
$0 == e && inblock == 1 {inblock=0; next}
!inblock {print}
' "$SMB_CONF" > "$tmp"
{
echo
echo "# >>> pos-managed share: $name"
printf '%s\n' "$block"
echo "# <<< end pos-managed share"
} >> "$tmp"
testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; }
sudo cp "$tmp" "$SMB_CONF"
rm -f "$tmp"
reload_config
log "Share added: [$name] → $path"
notify_send "SMB share added: $name ($path)"
testparm -s "$tmp" >/dev/null || { rm -f "$tmp"; err "Invalid smb.conf — changes not applied (see testparm -s $SMB_CONF)"; }
sudo cp "$tmp" "$SMB_CONF"
rm -f "$tmp"
reload_config
log "Share added: [$name] → $path"
notify_send "SMB share added: $name ($path)"
}
cmd_unshare() {
local name="$1" tmp
validate_share_name "$name"
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then
warn "No share '$name' found in $SMB_CONF"
exit 0
fi
tmp="$(mktemp)"
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
$0 == s {inblock=1}
$0 == e && inblock == 1 {inblock=0; next}
!inblock {print}
' "$SMB_CONF" > "$tmp"
sudo cp "$tmp" "$SMB_CONF"
rm -f "$tmp"
reload_config
log "Removed share: $name"
notify_send "SMB share removed: $name"
}
cmd_list() {
list_shares
}
cmd_adduser() {
local user="$1"
[ -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"
}
cmd_deluser() {
local user="$1"
[ -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"
}
cmd_reload() {
reload_config
}
cmd_enable() {
sudo systemctl enable --now smbd
log "smbd enabled (starts on boot)"
notify_send "SMB server enabled"
}
cmd_disable() {
sudo systemctl disable --now smbd
log "smbd disabled (will not start on boot)"
notify_send "SMB server disabled"
}
# ── Interactive menu flows ─────────────────────────────────────
menu_pick_folder() { # stdout: folder path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Share which folder?" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
[ -d "$dir" ] || { warn "Folder vanished: $dir"; return 1; }
echo "$dir"
return 0
fi
return 1
fi
dir="$(share_ask_value "Folder to share (absolute path)")" || return 1
[ -n "$dir" ] || { warn "No folder given"; return 1; }
echo "$dir"
}
menu_share() {
local dir name users
local -a args=()
dir="$(menu_pick_folder)" || return 1
name="$(share_ask_value "Share name" "$(basename "$dir")")" || return 1
[ -n "$name" ] || name="$(basename "$dir")"
if confirm "Read-only share?" n; then
args+=(--read-only)
fi
if confirm "Guest access (ANY network user, no login)?" n; then
args+=(--guest)
else
users="$(share_ask_value "Restrict to Samba users (comma-separated, empty = any account)")" || return 1
[ -n "$users" ] && args+=(--users "$users")
fi
cmd_share "$dir" "$name" ${args[@]+"${args[@]}"}
}
menu_unshare() {
local idx name
local -a names=()
if mapfile -t names < <(awk '/^# >>> pos-managed share: /{ sub(/^# >>> pos-managed share: /, ""); print }' "$SMB_CONF" 2>/dev/null) &&
[ "${#names[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which share?" "${names[@]}")" || return 1
name="${names[$((idx - 1))]}"
else
name="$(share_ask_value "Share name to remove")" || return 1
[ -n "$name" ] || return 1
fi
cmd_unshare "$name"
}
menu_adduser() {
local user
user="$(share_ask_value "System user to give a Samba account")" || return 1
[ -n "$user" ] || return 1
cmd_adduser "$user"
}
menu_deluser() {
local idx user
local -a users=()
if mapfile -t users < <(sudo pdbedit -L 2>/dev/null | cut -d: -f1) &&
[ "${#users[@]}" -gt 0 ]; then
idx="$(share_pick "Remove which Samba user?" "${users[@]}")" || return 1
user="${users[$((idx - 1))]}"
else
user="$(share_ask_value "Samba user to remove")" || return 1
[ -n "$user" ] || return 1
fi
cmd_deluser "$user"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "Samba server" \
"Show status (shares/users/ports/firewall)" \
"Share a folder" \
"Remove a share" \
"List current shares" \
"Add a Samba user" \
"Remove a Samba user" \
"Validate + reload config" \
"Enable service on boot" \
"Disable service")" || return 0
case "$choice" in
1) cmd_status ;;
2) menu_share ;;
3) menu_unshare ;;
4) cmd_list ;;
5) menu_adduser ;;
6) menu_deluser ;;
7) cmd_reload ;;
8) cmd_enable ;;
9) cmd_disable ;;
esac
done
}
case "$cmd" in
""|menu)
run_menu
exit 0
;;
status) cmd_status ;;
share)
[ $# -ge 2 ] || err "Usage: pos share smb-server share <path> [name] [--read-only|--guest|--users u1,u2]"
cmd_share "${@:2}"
;;
unshare)
name="${2:-}"
[ -n "$name" ] || err "Usage: pos share smb-server unshare <name>"
validate_share_name "$name"
[ -f "$SMB_CONF" ] || err "No smb.conf at $SMB_CONF (is samba installed?)"
if ! grep -Fq "# >>> pos-managed share: $name" "$SMB_CONF"; then
warn "No share '$name' found in $SMB_CONF"
exit 0
fi
tmp="$(mktemp)"
awk -v s="# >>> pos-managed share: $name" -v e="# <<< end pos-managed share" '
$0 == s {inblock=1}
$0 == e && inblock == 1 {inblock=0; next}
!inblock {print}
' "$SMB_CONF" > "$tmp"
sudo cp "$tmp" "$SMB_CONF"
rm -f "$tmp"
reload_config
log "Removed share: $name"
notify_send "SMB share removed: $name"
[ $# -ge 2 ] || err "Usage: pos share smb-server unshare <name>"
cmd_unshare "$2"
;;
list)
list_shares
;;
list) cmd_list ;;
adduser)
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"
[ $# -ge 2 ] || err "Usage: pos share smb-server adduser <user>"
cmd_adduser "$2"
;;
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"
[ $# -ge 2 ] || err "Usage: pos share smb-server deluser <user>"
cmd_deluser "$2"
;;
reload)
reload_config
;;
reload) cmd_reload ;;
enable)
sudo systemctl enable --now smbd
log "smbd enabled (starts on boot)"
notify_send "SMB server enabled"
;;
enable) cmd_enable ;;
disable)
sudo systemctl disable --now smbd
log "smbd disabled (will not start on boot)"
notify_send "SMB server disabled"
;;
disable) cmd_disable ;;
esac
+147 -3
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: share usb-server — USB Redirector server control (--ls, --share; prompts when args omitted)
# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version
# POS_FLAGS: --ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$0")/share-lib.sh"
usage() {
cat <<EOF
@@ -188,18 +189,161 @@ cmd_port() {
command -v usbsrv &>/dev/null \
|| err "usbsrv not found — install the USB Redirector server (https://www.incentivespro.com/usb-server.html)"
# ── Interactive menu flows ─────────────────────────────────────
# Picker-first prompting: parsed listings feed the filtered picker; when the
# server is unreachable or nothing parses, fall back to the same raw-listing +
# manual-entry prompts the explicit commands have always used.
menu_pick_from_records() { # <prompt> <records-cmd...> — stdout: chosen ID · rc 1 cancel/fallback
local prompt="$1"; shift
local idx rec
local -a ids=()
if recs="$("$@")" && [ -n "$recs" ]; then
while IFS= read -r rec; do
[ -n "$rec" ] || continue
ids+=("${rec%%|*}")
done <<<"$recs"
if [ "${#ids[@]}" -gt 0 ]; then
if idx="$(share_pick "$prompt" "${ids[@]}")"; then
echo "${ids[$((idx - 1))]}"
return 0
fi
fi
fi
return 1
}
menu_share() {
local dev client
if ! dev="$(menu_pick_from_records "Share which USB device?" share_usb_devices)"; then
cmd_ls >/dev/null 2>&1 || true
read -rp "Enter device ID to share: " dev
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
fi
if ! client="$(menu_pick_from_records "Connect to which client?" share_usb_clients)"; then
read -rp "Enter client ID to connect to: " client
[ -n "$client" ] || { warn "Cancelled"; return 1; }
fi
cmd_share "$dev" "$client"
}
menu_unshare() {
local dev
if ! dev="$(menu_pick_from_records "Stop sharing which device?" share_usb_devices)"; then
cmd_ls_shared >/dev/null 2>&1 || true
read -rp "Enter device ID to unshare: " dev
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
fi
cmd_unshare "$dev"
}
menu_callback() {
local addr
addr="$(share_ask_value "Client address:port for callback (e.g. 192.168.1.100:32032)")" || return 1
[ -n "$addr" ] || { warn "Cancelled"; return 1; }
cmd_callback "$addr"
}
menu_close_callback() {
local target
if ! target="$(menu_pick_from_records "Close callback of which client?" share_usb_clients)"; then
target="$(share_ask_value "Client, client id or 'all' to close callback")" || return 1
[ -n "$target" ] || { warn "Cancelled"; return 1; }
fi
cmd_close_callback "$target"
}
menu_auto_connect() {
local mode client
mode="$(share_ask_value "Remote auto-connect on or off?" "on")" || return 1
case "$mode" in on|off) ;; *) warn "must be on/off"; return 1 ;; esac
if ! client="$(menu_pick_from_records "Toggle auto-connect for which client?" share_usb_clients)"; then
client="$(share_ask_value "Client or client id")" || return 1
[ -n "$client" ] || { warn "Cancelled"; return 1; }
fi
cmd_auto_connect "$mode" "$client"
}
menu_disconnect() {
local dev
if ! dev="$(menu_pick_from_records "Disconnect which device?" share_usb_devices)"; then
dev="$(share_ask_value "Device ID or 'all' to disconnect from clients")" || return 1
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
fi
cmd_disconnect "$dev"
}
menu_nickname() {
local dev nick
if ! dev="$(menu_pick_from_records "Nickname which device?" share_usb_devices)"; then
dev="$(share_ask_value "Device ID")" || return 1
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
fi
nick="$(share_ask_value "Nickname (empty removes it)")" || return 1
cmd_nickname "$dev" "$nick"
}
menu_timeout() {
local dev sec
if ! dev="$(menu_pick_from_records "Set inactivity timeout for which device?" share_usb_devices)"; then
dev="$(share_ask_value "Device ID")" || return 1
[ -n "$dev" ] || { warn "Cancelled"; return 1; }
fi
sec="$(share_ask_value "Timeout in seconds (0 disables)" "0")" || return 1
cmd_timeout "$dev" "$sec"
}
menu_port() {
local port
port="$(share_ask_value "New TCP port")" || return 1
[ -n "$port" ] || { warn "Cancelled"; return 1; }
cmd_port "$port"
}
run_menu() {
share_menu_guard || exit 1
while true; do
local choice
choice="$(share_menu_run "USB Redirector server" \
"List host devices and connected clients" \
"Share a device with a client" \
"Stop sharing a device" \
"Disconnect device(s) from clients" \
"Auto-share on/off" \
"Create a callback connection" \
"Close a client callback" \
"Client remote auto-connect on/off" \
"Set a device nickname" \
"Set a device inactivity timeout" \
"Set the TCP port")" || return 0
case "$choice" in
1) cmd_ls ;;
2) menu_share ;;
3) menu_unshare ;;
4) menu_disconnect ;;
5) cmd_auto_share ;;
6) menu_callback ;;
7) menu_close_callback ;;
8) menu_auto_connect ;;
9) menu_nickname ;;
10) menu_timeout ;;
11) menu_port ;;
esac
done
}
cmd="${1:-}"
case "$cmd" in
-h|--help|"") usage ;;
-h|--help) usage ;;
esac
case "$cmd" in
--ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;;
""|menu|--ls|--ls-shared|--share|--unshare|--auto-share|--callback|--close-callback|--auto-connect|--disconnect|--nickname|--timeout|--port|--info|--version) ;;
*) err "Unknown flag '$cmd'" ;;
esac
case "$cmd" in
""|menu) run_menu ;;
--ls) cmd_ls ;;
--ls-shared) cmd_ls_shared ;;
--share) shift; cmd_share "$@" ;;
+318
View File
@@ -0,0 +1,318 @@
# lib/share-lib.sh — precondition probes, remote listings and advisories for
# the `pos share` suite (nfs/smb/usb tools), plus compat shims to the
# category-neutral menu layer (lib/menu-lib.sh).
#
# Contracts (all of them, no exceptions):
# * Defines ONLY `share_*` functions — sourcing never clobbers a tool's own
# helpers (same discipline as lib/notify.sh).
# * Requires common.sh to be sourced by the CALLER first (log/warn/err/
# confirm/run/spawn + CYAN/RESET colors are used, never defined here).
# * NEVER exits and never terminates the caller: every function returns,
# failures are signalled through the return code.
# * Display goes to stderr, results go to stdout — any function whose result
# is meant to be command-substituted prints ONLY the result on stdout
# (matches the ui_pick contract in the telegram/matrix listeners).
# * Performs NO file writes of its own — every path a tool persists stays
# tool-owned (the env-seam surface does not grow here).
#
# Function index:
# share_menu_run <title> <item...> shim → menu_run (lib/menu-lib.sh)
# share_pick <prompt> <item...> shim → menu_pick (lib/menu-lib.sh)
# share_ask_value <label> [default] shim → menu_ask_value (lib/menu-lib.sh)
# share_menu_guard shim → menu_guard (lib/menu-lib.sh)
# share_require_bin <bin> <hint> dependency probe (rc only)
# share_port_probe <host> <port> TCP reachability probe (rc only)
# share_service_active <unit> systemd unit state probe (rc only)
# share_ufw_blocks_ports <egrep> ufw-blocking decision (rc only)
# share_offer_fix <desc> <cmd...> advisory remediation offer (rc always 0)
# share_nfs_exports <host> remote export list via showmount
# share_smb_shares <host> [user] remote Disk-share list via smbclient
# share_usb_devices / _clients usbsrv listing records as "ID|display"
# share_folder_candidates mountpoint/dir candidates for sharing
# ── Menu primitives live in lib/menu-lib.sh (category-neutral) ─
# The generic interactive layer was extracted there; these thin shims keep the
# public `share_*` names/contracts identical for all five share tools. Delegation
# preserves rc semantics 1:1 (guard rc, EOF → rc 1, index/value → stdout only).
source "$(dirname "${BASH_SOURCE[0]}")/../lib/menu-lib.sh" 2>/dev/null \
|| source "$(dirname "${BASH_SOURCE[0]}")/menu-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null \
|| source "$(dirname "$0")/menu-lib.sh"
share_menu_guard() { menu_guard "$@"; }
share_menu_run() { menu_run "$@"; }
share_pick() { menu_pick "$@"; }
share_ask_value() { menu_ask_value "$@"; }
# ── Dependency probe (NOT an err wrapper) ──────────────────────
# rc 0 present · rc 1 absent. Callers decide between err() and graceful
# degradation; <hint> documents intent at call sites and is intentionally
# not printed here (message policy belongs to the caller).
share_require_bin() {
command -v "$1" >/dev/null 2>&1
}
# ── TCP reachability probe (generalized probe_server core) ─────
# rc 0 reachable within 3s · rc 1 unreachable/no-route. Message policy (targeted
# hints, firewall wording) belongs to the caller.
share_port_probe() {
timeout 3 bash -c "exec 3<>/dev/tcp/${1}/${2}" 2>/dev/null
}
# ── systemd unit state probe ───────────────────────────────────
# rc 0 active · rc 1 inactive/unqueryable (nonzero systemctl codes normalized).
share_service_active() {
if systemctl is-active --quiet "$1" 2>/dev/null; then
return 0
fi
return 1
}
# ── Bounded path probe ─────────────────────────────────────────
# share_path_probe <-d|-w> <path> — rc 0 when the stat answers within 2s.
# Guards discovery against wedged network mountpoints where a plain
# `[ -d … ]` would block forever.
share_path_probe() {
timeout 2 bash -c '[ "$1" "$2" ]' _ "$1" "$2" 2>/dev/null
}
# ── ufw blocking decision ──────────────────────────────────────
# Core moved verbatim from pos-share-smb-server's ufw_blocks_samba, with the
# rule pattern parameterized. rc 0 = ufw active but no matching rule (traffic
# blocked) · rc 1 = ufw absent/inactive OR a matching rule exists (not blocking).
# Callers own all message text.
share_ufw_blocks_ports() {
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 "$1" <<<"$st"; then
return 1
fi
return 0
}
# ── Advisory remediation offer ─────────────────────────────────
# On a terminal: confirm "<desc>. Fix it now?" (default No) and run/spawn the
# command, reporting the outcome. Without a terminal: print desc + manual
# command as a hint-only warning. ALWAYS rc 0 — purely advisory; aborting on a
# declined offer stays the caller's confirm/err decision.
share_offer_fix() {
local desc="$1"; shift
if [ -t 0 ]; then
echo >&2
if confirm "$desc. Fix it now?" n; then
if run "$@"; then
ok "Fixed: $*"
else
warn "Fix command failed: $*"
fi
else
warn "Declined — run manually: $*"
fi
else
warn "$desc — run manually: $*"
fi
return 0
}
# ── Remote NFS export enumeration ──────────────────────────────
# stdout: export paths, one per row (column 1 of the showmount table, header
# skipped). rc 0 ok · rc 1 unavailable (showmount missing / timeout / RPC
# failure / no exports) with the reason warned on stderr.
share_nfs_exports() {
local host="$1" out rows
if ! command -v showmount >/dev/null 2>&1; then
printf '[!] showmount not found (install nfs-common) — cannot list exports from %s\n' "$host" >&2
return 1
fi
if ! out="$(timeout 5 showmount -e "$host" 2>&1)"; then
printf "[!] could not list exports from %s (server down, RPC/firewall blocked, or timeout)\n" "$host" >&2
return 1
fi
rows="$(awk 'NF > 0 && $1 !~ /^Export/ {print $1}' <<<"$out")"
if [ -z "$rows" ]; then
printf "[!] no exports visible on %s\n" "$host" >&2
return 1
fi
printf '%s\n' "$rows"
}
# ── Remote SMB share enumeration ───────────────────────────────
# stdout: Disk share names, one per line (-g parse; IPC$/printer `*$` names
# dropped). Sets SMB_AUTH_USER="" at entry, and to the account that ended up
# authenticating, so callers can reuse it for the actual mount:
# * user argument given → authenticate immediately (password prompted,
# travels via the PASSWD environment, never argv); no guest attempt.
# * no user argument → guest query first; on ACCESS_DENIED /
# LOGON_FAILURE a Samba user + password are asked once (TTY required)
# and the query retries.
# rc 0 ok · rc 1 unavailable / failed-after-retry (reason warned on stderr).
share_smb_shares() {
local host="$1" user="${2:-}" out names pw u
local -r GPARSE='BEGIN { FS = "|" } $1 == "Disk" && $2 != "" && $2 != "IPC$" && $2 !~ /\$$/ { print $2 }'
SMB_AUTH_USER=""
if ! command -v smbclient >/dev/null 2>&1; then
printf '[!] smbclient not found — cannot enumerate shares (install the smbclient package)\n' >&2
return 1
fi
if [ -n "$user" ]; then
if ! [ -t 0 ]; then
printf '[!] Samba login for %s needs a terminal (password prompt)\n' "$host" >&2
return 1
fi
read -rsp "Samba password for $user: " pw || { echo >&2; return 1; }
echo >&2
if out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$user" 2>&1)"; then
SMB_AUTH_USER="$user"
else
printf '[!] share enumeration failed for %s@%s (wrong user/password?)\n' "$user" "$host" >&2
return 1
fi
else
if ! out="$(smbclient -L "//${host}/" -N -g -t 5 2>&1)"; then
case "$out" in
*ACCESS_DENIED*|*LOGON_FAILURE*|*NOT_GRANTED*)
if ! [ -t 0 ]; then
printf '[!] %s requires authentication — rerun interactively (menu) to enter a Samba user\n' "$host" >&2
return 1
fi
printf '[!] %s rejected guest access — a Samba login is required\n' "$host" >&2
u="$(share_ask_value "Samba user for ${host}")" || return 1
read -rsp "Samba password for $u: " pw || { echo >&2; return 1; }
echo >&2
if ! out="$(PASSWD="$pw" smbclient -L "//${host}/" -g -t 5 -U "$u" 2>&1)"; then
printf '[!] share enumeration failed for %s@%s after retry\n' "$u" "$host" >&2
return 1
fi
SMB_AUTH_USER="$u"
;;
*)
printf '[!] could not enumerate shares on %s: %s\n' "$host" "$(head -1 <<<"$out")" >&2
return 1
;;
esac
fi
fi
names="$(printf '%s\n' "$out" | awk "$GPARSE")"
if [ -z "$names" ]; then
printf '[!] no Disk shares visible on %s\n' "$host" >&2
return 1
fi
printf '%s\n' "$names"
}
# ── USB Redirector listings ────────────────────────────────────
# Both emit blank-line-split records as "ID|display-line" rows; rc 0 parsed ≥1
# record · rc 1 unparsable/down — callers fall back to the raw listing plus a
# manual ID entry (= today's UX).
share_usb_records() { # internal helper: stdin = raw listing, $1 = ID-line regex
awk -v idre="$1" '
BEGIN { RS = "" }
{
n = split($0, L, "\n")
id = ""; desc = ""; fb = ""
for (i = 1; i <= n; i++) {
line = L[i]
if (line ~ idre && id == "") {
id = line
sub(/^[^:]*:[ \t]*/, "", id)
} else if (line ~ /^Description:/) {
desc = line
sub(/^Description:[ \t]*/, "", desc)
} else if (fb == "") {
fb = line
}
}
if (id != "") print id "|" (desc != "" ? desc : fb)
}'
}
share_usb_devices() {
local raw recs
if ! command -v usbsrv >/dev/null 2>&1; then
printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2
return 1
fi
if ! raw="$(usbsrv -list-devices 2>&1)"; then
printf '[!] usbsrv -list-devices failed — is the USB Redirector server running?\n' >&2
return 1
fi
recs="$(printf '%s\n' "$raw" | share_usb_records '^ID:')"
if [ -z "$recs" ]; then
printf '[!] no USB device records could be parsed from the server listing\n' >&2
return 1
fi
printf '%s\n' "$recs"
}
share_usb_clients() {
local raw recs
if ! command -v usbsrv >/dev/null 2>&1; then
printf '[!] usbsrv not found — install the USB Redirector server first\n' >&2
return 1
fi
if ! raw="$(usbsrv -list-clients 2>&1)"; then
printf '[!] usbsrv -list-clients failed — is the USB Redirector server running?\n' >&2
return 1
fi
recs="$(printf '%s\n' "$raw" | share_usb_records '^Client ID:')"
if [ -z "$recs" ]; then
printf '[!] no connected clients could be parsed from the server listing\n' >&2
return 1
fi
printf '%s\n' "$recs"
}
# ── Share-folder candidates ────────────────────────────────────
# stdout: candidate paths, one per line — writable real-filesystem findmnt
# targets (pseudo-fs denylist and read-only mounts excluded) immediate
# directories under /mnt,/srv,/media,/export. LC_ALL=C sorted, deduplicated;
# entries that are mountpoints carry a "(mounted <fstype>)" annotation.
# rc 0 always; an empty list is allowed (caller falls back to manual entry).
share_folder_candidates() {
local -A seen=()
local line fs tgt opts entry child r path fst
local -a cands=()
# findmnt targets: split TARGET FSTYPE OPTIONS (TARGET may hold escaped
# spaces, OPTIONS/FSTYPE never do) — pseudo-fs denylist + ro exclusion.
while IFS=$'\t' read -r fs tgt opts; do
[ -n "$tgt" ] || continue
case "$fs" in
proc | sysfs | devtmpfs | devpts | tmpfs | cgroup | cgroup2 | squashfs | \
overlay | mqueue | hugetlbfs | debugfs | tracefs | configfs | fusectl | \
securityfs | pstore | efivarfs | bpf | ramfs | nsfs | binfmt_misc | autofs | \
iso9660 | swap | fuse.* | cgroupfs) continue ;;
esac
case ",$opts," in *,ro,*) continue ;; esac
share_path_probe -w "$tgt" || continue
cands+=("${tgt}|${fs}")
done < <(timeout 5 findmnt -rn -o TARGET,FSTYPE,OPTIONS 2>/dev/null |
awk '{ opts=$NF; fstype=$(NF-1); tgt=substr($0, 1, length($0)-length(opts)-length(fstype)-1); sub(/[ \t]+$/, "", tgt); print fstype "\t" tgt "\t" opts }')
# immediate directories under the conventional share roots
for r in /mnt /srv /media /export; do
[ -d "$r" ] || continue
for child in "$r"/*; do
share_path_probe -d "$child" || continue
cands+=("${child}|")
done
done
# annotate mountpoints, prefer annotated duplicates, sort byte-order
for line in "${cands[@]}"; do
path="${line%%|*}"
fst="${line#*|}"
if [ -n "$fst" ]; then
seen["$path"]="${path} (mounted ${fst})"
elif [ -z "${seen[$path]:-}" ]; then
seen["$path"]="$path"
fi
done
for path in "${!seen[@]}"; do
printf '%s\n' "${seen[$path]}"
done | LC_ALL=C sort -u
}
+1 -1
View File
@@ -31,7 +31,7 @@ PACKAGES=(
net-tools iputils-ping traceroute tcpdump nmap
openssh-client openssh-server ufw fail2ban
nfs-common nfs-kernel-server
samba cifs-utils
samba cifs-utils smbclient
hostapd dnsmasq iptables iw
ca-certificates gnupg lsb-release
lm-sensors smartmontools nvme-cli hdparm