One liveness primitive instead of two. The PID argument was a vestige (format-checked, never probed, since the kill -0 removal); dropping it means a roster with a garbage SERVER_PID can no longer mask a live socket in host/moderation/role resolution.
1461 lines
60 KiB
Bash
Executable file
1461 lines
60 KiB
Bash
Executable file
#!/bin/sh
|
||
# party: share a tmux session with the people you already work with.
|
||
#
|
||
# Single-file POSIX shell.
|
||
#
|
||
# Project by veg & kol3rby for the UNIX Social Club <https://club.unix.rocks>
|
||
|
||
set -eu
|
||
|
||
PARTY_VERSION=0.1.0
|
||
|
||
# Overridable defaults
|
||
# ====================
|
||
|
||
# $USER may be unset in stripped environments (cron, systemd unit shells,
|
||
# `env -i` invocations). Every cmd_* path references it, so guarantee it
|
||
# now via id -un rather than crashing later under set -eu.
|
||
: "${USER:=$(id -un)}"
|
||
: "${TMUX_PARTY_GROUP:=party}"
|
||
: "${PARTY_SOCKET_DIR:=/tmp}"
|
||
: "${PARTY_TMUX:=tmux}"
|
||
|
||
# Usage
|
||
# =====
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
Usage: party <subcommand> [args]
|
||
|
||
Hosting:
|
||
host [name] [--group <g>]
|
||
Start a party on a dedicated tmux server.
|
||
Names: letters, digits, '_', '-' (max 63).
|
||
close [name] Tear down the party you host (name it when
|
||
you host several).
|
||
|
||
Joining:
|
||
join [name] [--passive] Join a party. Without name: auto-pick or numbered prompt.
|
||
--passive lands in the host's session (mirrored view).
|
||
Read-only invitees always join passive.
|
||
leave Detach from the party you joined.
|
||
role [active|passive|switch]
|
||
Flip your clients between guest session and host session.
|
||
|
||
Moderation (host only; --party <name> disambiguates multiple parties):
|
||
invite <user> [-r] Add user to allowlist (read/write; -r for read-only).
|
||
voice <user> Promote user to read/write (alias: rw).
|
||
mute <user> Demote user to read-only (alias: ro).
|
||
kick <user> Revoke invite + disconnect + clean up guest session.
|
||
detach <user> Disconnect user; keep them on the allowlist.
|
||
|
||
Info:
|
||
list List discoverable parties on this host.
|
||
who [--short] [--party <name>]
|
||
Show invited and attached users for a party.
|
||
status Show the caller's own party state.
|
||
--help This message.
|
||
--version Print version.
|
||
EOF
|
||
}
|
||
|
||
# Library mode
|
||
# ============
|
||
|
||
# Sourced from tests with __PARTY_LIB_ONLY=1 to expose helpers without
|
||
# triggering dispatch. The marker MUST be checked before the dispatch
|
||
# block at the bottom of the file.
|
||
|
||
# Helpers
|
||
# =======
|
||
|
||
# Validate a party name. Accepts [A-Za-z0-9_-]{1,63}, not starting with
|
||
# '-'. Reject empty, paths, shell metas, and overlong names: the name
|
||
# lands in a file path component, a tmux session name, and shell
|
||
# command lines. Dots are rejected because tmux's session_check_name()
|
||
# silently rewrites '.' (and ':') to '_', which would desync the party
|
||
# name from its tmux session and break every later `-t <name>` target.
|
||
# Leading '-' is rejected because every subcommand parser would eat the
|
||
# name as a flag, so such a party could never be addressed.
|
||
validate_party_name() {
|
||
name="$1"
|
||
case "$name" in
|
||
'' ) echo "party: name must not be empty" >&2; return 2 ;;
|
||
esac
|
||
# Length cap: 63 keeps the full socket path comfortably below the
|
||
# 108-char sun_path limit for typical USERs.
|
||
if [ "${#name}" -gt 63 ]; then
|
||
echo "party: name '$name' too long (max 63 chars)" >&2
|
||
return 2
|
||
fi
|
||
case "$name" in
|
||
-* )
|
||
echo "party: name must not start with '-'" >&2
|
||
return 2 ;;
|
||
*[!A-Za-z0-9_-]* )
|
||
echo "party: name '$name' has invalid characters" >&2
|
||
echo " allowed: letters, digits, '_', '-'" >&2
|
||
return 2 ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# UTC ISO 8601, portable across BSD/GNU date.
|
||
iso_now() {
|
||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||
}
|
||
|
||
# Boolean: does the named OS user exist?
|
||
user_exists() {
|
||
id -u "$1" >/dev/null 2>&1
|
||
}
|
||
|
||
# Boolean: is the named user in TMUX_PARTY_GROUP?
|
||
user_in_party_group() {
|
||
user="$1"
|
||
id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$TMUX_PARTY_GROUP"
|
||
}
|
||
|
||
# Soft assertion: caller is in TMUX_PARTY_GROUP. Fails loudly with
|
||
# remediation instructions when not. Called by cmd_host before any
|
||
# filesystem work; the other subcommands don't need it: non-members
|
||
# are stopped by the FS perimeter itself (EACCES at the per-party dir)
|
||
# and get the friendlier group-gate message in cmd_join.
|
||
require_caller_in_group() {
|
||
if ! user_in_party_group "$USER"; then
|
||
cat >&2 <<EOF
|
||
party: you ($USER) are not in the '$TMUX_PARTY_GROUP' group.
|
||
|
||
One-time setup (as root), your OS may differ:
|
||
Linux: groupadd $TMUX_PARTY_GROUP && usermod -aG $TMUX_PARTY_GROUP $USER
|
||
FreeBSD: pw groupadd $TMUX_PARTY_GROUP && pw groupmod $TMUX_PARTY_GROUP -m $USER
|
||
OpenBSD: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
|
||
illumos: groupadd $TMUX_PARTY_GROUP && usermod -G $TMUX_PARTY_GROUP $USER
|
||
Then log out and back in.
|
||
EOF
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Roster paths
|
||
# ------------
|
||
|
||
# Path separators are ':' rather than '-' because party names may contain
|
||
# '-' (validate_party_name allows it) and we don't want USER-foo-bar to
|
||
# collide with USER:foo-bar / USER-foo:bar etc. POSIX usernames cannot
|
||
# contain ':' and validate_party_name rejects it, so the separator is
|
||
# unambiguous.
|
||
|
||
# Per-party private directory. Created at host time, removed at close.
|
||
# Holds the tmux socket, the notify helper, and the roster record. The
|
||
# directory's group + mode IS the discovery perimeter: members of
|
||
# $TMUX_PARTY_GROUP can traverse and read; non-members get EACCES at
|
||
# the dir, so the roster inside is invisible to them. No shared
|
||
# install directory: each party is a self-contained perimeter.
|
||
party_dir_path() {
|
||
printf '%s/party-%s:%s.d\n' "$PARTY_SOCKET_DIR" "$1" "$2"
|
||
}
|
||
|
||
roster_record_path() {
|
||
printf '%s/roster\n' "$(party_dir_path "$1" "$2")"
|
||
}
|
||
|
||
socket_path() {
|
||
printf '%s/sock\n' "$(party_dir_path "$1" "$2")"
|
||
}
|
||
|
||
# Roster I/O
|
||
# ----------
|
||
#
|
||
# Records are flat shell-sourceable key=value files:
|
||
#
|
||
# HOST_USER=veg
|
||
# PARTY_NAME=debug-the-deploy
|
||
# SOCKET=/tmp/party-veg:debug-the-deploy.d/sock
|
||
# SERVER_PID=12345
|
||
# GROUP=party
|
||
# CREATED=2026-04-26T18:42:00Z
|
||
#
|
||
# Schema: keys MUST be from the set above. Values are single-line, no
|
||
# spaces or special chars (we control all writes).
|
||
|
||
# Atomic write: temp file then rename.
|
||
# Usage: roster_write <path> <KEY1> <VAL1> <KEY2> <VAL2> ...
|
||
roster_write() {
|
||
rec="$1"; shift
|
||
tmp="${rec}.tmp.$$"
|
||
: > "$tmp"
|
||
while [ $# -ge 2 ]; do
|
||
printf '%s=%s\n' "$1" "$2" >> "$tmp"
|
||
shift 2
|
||
done
|
||
chmod 0640 "$tmp" 2>/dev/null || true
|
||
mv "$tmp" "$rec"
|
||
}
|
||
|
||
# Read a roster record, exporting RR_<KEY> into the calling shell.
|
||
#
|
||
# Trust model: security-relevant fields (RR_HOST_USER, RR_PARTY_NAME,
|
||
# RR_SOCKET) come from the filesystem, the dir basename and the dir's
|
||
# owner, never from the file's content. The roster file itself supplies
|
||
# advisory metadata (RR_SERVER_PID, RR_GROUP, RR_CREATED) which is fine
|
||
# to display but not to act on.
|
||
#
|
||
# This anchors trust in unforgeable primitives: mkdir(2) and bind(2) set
|
||
# the owner to the calling euid, and `chown` requires root. A group
|
||
# member who plants a forged dir cannot fake its ownership, cannot
|
||
# redirect RR_SOCKET (always `$d/sock`), and cannot forge a sock
|
||
# elsewhere (we reject `$d` and `$d/sock` symlinks; we require both
|
||
# inodes to be owned by the user named in the basename, which catches
|
||
# hardlinks pointing at sibling-party sockets).
|
||
#
|
||
# What we deliberately don't try to defend against: a group member
|
||
# hosting a real party that appears in discovery (that's just hosting),
|
||
# and TOCTOU swaps inside an attacker's own dir between roster_read and
|
||
# the subsequent tmux call (window is short; damage cap is "any tmux op
|
||
# the caller was already authorized to perform").
|
||
#
|
||
# That damage cap holds because the swap only reaches discovery paths
|
||
# (join/leave/status), which act on the caller's own clients/guest
|
||
# session, never on someone else's server. The host-only moderation
|
||
# paths (kick/detach/voice/mute) can't be redirected at all: they route
|
||
# through resolve_authoritative_party, which gates on [ -O "$d" ], so the
|
||
# dir is the caller's own (mode 0750, group r-x, no group write) under a
|
||
# sticky parent, a group member can neither replace its sock nor rename
|
||
# the dir, so there is no socket to swap out from under the tmux call.
|
||
roster_read() {
|
||
rec="$1"
|
||
[ -f "$rec" ] || return 1
|
||
|
||
d="${rec%/roster}"
|
||
[ -L "$d" ] && return 1
|
||
[ -L "$d/sock" ] && return 1
|
||
[ -e "$d/sock" ] || return 1
|
||
|
||
base="${d##*/}"
|
||
case "$base" in
|
||
party-*:*.d) ;;
|
||
*) return 1 ;;
|
||
esac
|
||
rest="${base#party-}"
|
||
rest="${rest%.d}"
|
||
RR_HOST_USER="${rest%%:*}"
|
||
RR_PARTY_NAME="${rest#*:}"
|
||
[ -n "$RR_HOST_USER" ] || return 1
|
||
[ -n "$RR_PARTY_NAME" ] || return 1
|
||
|
||
# Pattern parity with cmd_host. The basename glob (party-*:*.d) is
|
||
# permissive: names like 'a b' or 'my.party' match the glob but
|
||
# validate_party_name rejects them (dots are banned: tmux rewrites
|
||
# them in session names). Run the canonical validator so the
|
||
# invariant cmd_host enforces on write is also checked on read.
|
||
# Side effect: pre-existing dotted-party dirs from older builds
|
||
# become undiscoverable, which is the pre-release migration policy
|
||
# (README §Status).
|
||
validate_party_name "$RR_PARTY_NAME" 2>/dev/null || return 1
|
||
|
||
# Both the dir and its sock must be owned by the user named in the
|
||
# basename. -prune halts find's descent so we examine just the path
|
||
# itself. find errors (unknown user, missing path) suppress to no
|
||
# output → reject.
|
||
[ -n "$(find "$d" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1
|
||
[ -n "$(find "$d/sock" -prune -user "$RR_HOST_USER" 2>/dev/null)" ] || return 1
|
||
|
||
# Mode hardening for cross-user discovery. cmd_host writes the
|
||
# per-party dir as mode 0750, no other-bits set. A non-member
|
||
# outside TMUX_PARTY_GROUP cannot read or traverse such a dir, so
|
||
# anything surfacing in roster_list with other-readable bits is
|
||
# suspect: a local user planted a world-readable
|
||
# party-attacker:foo.d under a sticky parent (e.g. /tmp) to inject
|
||
# a fake party into our `party list` / `party join` flow. Reject
|
||
# so the spoof never reaches a tmux command. POSIX find lacks "/"
|
||
# or "+" perm operators (BSD/GNU extensions), so test each
|
||
# other-bit alone.
|
||
[ -n "$(find "$d" -prune -perm -0001 2>/dev/null)" ] && return 1 # o+x
|
||
[ -n "$(find "$d" -prune -perm -0002 2>/dev/null)" ] && return 1 # o+w
|
||
[ -n "$(find "$d" -prune -perm -0004 2>/dev/null)" ] && return 1 # o+r
|
||
|
||
RR_SOCKET="$d/sock"
|
||
RR_SERVER_PID='' RR_GROUP='' RR_CREATED=''
|
||
while IFS='=' read -r k v; do
|
||
# shellcheck disable=SC2034 # RR_CREATED is part of the public schema
|
||
case "$k" in
|
||
SERVER_PID) RR_SERVER_PID=$v ;;
|
||
GROUP) RR_GROUP=$v ;;
|
||
CREATED) RR_CREATED=$v ;;
|
||
'' | \#*) ;;
|
||
esac
|
||
done < "$rec"
|
||
return 0
|
||
}
|
||
|
||
# List full roster record paths discoverable to the caller. The glob
|
||
# walks every per-party private dir under PARTY_SOCKET_DIR; entries
|
||
# whose group the caller isn't in fail the inner [ -f ] check (EACCES
|
||
# on the dir) and are silently skipped.
|
||
roster_list() {
|
||
[ -d "$PARTY_SOCKET_DIR" ] || return 0
|
||
# No ownership/symlink gate here: roster_list is the cross-user
|
||
# discovery primitive (party list, party join, party leave, status),
|
||
# so it must surface dirs hosted by other group members. Host-only
|
||
# walkers (resolve_authoritative_party, no-arg cmd_close) apply
|
||
# their own owner+symlink filter before trusting HOST_USER.
|
||
for d in "$PARTY_SOCKET_DIR"/party-*:*.d; do
|
||
[ -d "$d" ] || continue
|
||
rec="$d/roster"
|
||
[ -f "$rec" ] || continue
|
||
printf '%s\n' "$rec"
|
||
done
|
||
}
|
||
|
||
|
||
# Liveness
|
||
# --------
|
||
|
||
# Classify the caller's relationship to a party socket. Prints exactly
|
||
# one of:
|
||
# ok : live tmux server, the caller is authorized.
|
||
# unauthorized : live tmux server, but it refused us. CAUTION, the
|
||
# reason this function exists: tmux answers a
|
||
# non-allowlisted user with "access not allowed" on
|
||
# stderr and EXIT STATUS 0 (verified live on 3.3a,
|
||
# 3.5a, and 3.6/SunOS), for every command, including
|
||
# has-session, which then reports any target as
|
||
# existing. Exit codes alone cannot distinguish
|
||
# authorized from unauthorized. We deliberately do NOT
|
||
# match the denial text either: wording could drift
|
||
# across tmux versions and silently reopen the
|
||
# false-authorized paths. A clean authorized
|
||
# list-clients writes nothing to stderr, so rc 0 plus
|
||
# ANY stderr output classifies as unauthorized. Should
|
||
# tmux ever warn on an authorized call, that degrades
|
||
# to a hidden party, never to a false "ok".
|
||
# dead : nothing speaking the tmux protocol at that socket
|
||
# (stale roster, killed server, AF_UNIX impostor).
|
||
#
|
||
# This probe is the ONLY liveness primitive. `tmux -S <sock>
|
||
# list-clients` exits 0 only against a real tmux server bound to that
|
||
# exact socket path, which rules out stale roster pointers, AF_UNIX
|
||
# impostors (a same-group user planting a hand-rolled listener via
|
||
# `nc -lU`), and PID-reuse leftovers in one shot. Earlier revisions had
|
||
# a separate is_party_alive() that also format-checked the roster's
|
||
# SERVER_PID, a vestige of a `kill -0` pre-filter that broke cross-user
|
||
# discovery on every non-Linux POSIX target: per POSIX, kill(2) signal
|
||
# 0 may return EPERM when the caller lacks send-permission, and
|
||
# illumos, all the BSDs, and macOS honor that. Linux (and LX-branded
|
||
# zones) return 0 for "exists, even if not signalable", which is why
|
||
# the bug hid in the test matrix until a native illumos run surfaced
|
||
# it. SERVER_PID stays in the roster as display-only metadata.
|
||
party_conn_state() {
|
||
_cs_err=$("$PARTY_TMUX" -S "$1" list-clients 2>&1 >/dev/null) \
|
||
&& _cs_rc=0 || _cs_rc=$?
|
||
if [ "$_cs_rc" -ne 0 ]; then
|
||
printf 'dead\n'
|
||
elif [ -n "$_cs_err" ]; then
|
||
printf 'unauthorized\n'
|
||
else
|
||
printf 'ok\n'
|
||
fi
|
||
}
|
||
|
||
# FS perms
|
||
# --------
|
||
#
|
||
# The OS group is the perimeter, tmux's server-access allowlist is the
|
||
# per-user filter. Both are universal across the supported platforms
|
||
# (Linux, FreeBSD, NetBSD, OpenBSD, illumos, Solaris, macOS), so the
|
||
# mechanism is one liner: chgrp + chmod. No ACL syscalls invoked.
|
||
#
|
||
# ACL-shadowing on filesystems with NFSv4-style extended ACLs (macOS
|
||
# HFS+/APFS, FreeBSD/illumos ZFS): inherited ACEs on PARTY_SOCKET_DIR
|
||
# can override the mode bits we set here, either widening access (an
|
||
# inherited `everyone@:rx` survives our chmod 0750 because mode bits
|
||
# don't displace ACEs) or narrowing it (a `deny everyone:` blocks the
|
||
# group members our chgrp tried to admit). We deliberately do NOT
|
||
# invoke `getfacl` / `getextattr` / `ls -le` to verify effective
|
||
# access. Two reasons:
|
||
#
|
||
# 1. Re-introducing per-platform ACL plumbing would unwind the
|
||
# simplification this redesign committed to: one universal
|
||
# mechanism, no OS branching in the trust path.
|
||
#
|
||
# 2. The dual gate keeps the FS layer from being the sole
|
||
# authorization check. tmux server-access is the authoritative
|
||
# perimeter on every platform; the FS gate is defense in depth.
|
||
# An ACL-shadowed FS gate is a *weakening of depth*, not a
|
||
# privilege escalation: a user who wins the FS-gate race still
|
||
# has to be on the auth-list to attach, and the auth-list is
|
||
# only edited by the host.
|
||
#
|
||
# Treat the FS gate as best-effort discoverability and traversal
|
||
# control. If you operate on a filesystem with restrictive inherited
|
||
# ACLs that chmod 0750 can't override, document it in your deployment
|
||
# notes, server-access still keeps strangers out, but advertised
|
||
# group members may need ACL-level access to traverse PARTY_SOCKET_DIR.
|
||
# See README §Trust model for the user-facing version of this caveat.
|
||
# WONTFIX, by design.
|
||
|
||
apply_party_perms_file() {
|
||
p="$1"
|
||
chgrp "$TMUX_PARTY_GROUP" "$p"
|
||
chmod g+rw "$p"
|
||
}
|
||
|
||
# Refuse to operate when PARTY_SOCKET_DIR is somewhere an attacker could
|
||
# rename our per-party directory between mkdir and use. Trustworthy is
|
||
# either: sticky (mode +t, like /tmp), or owned by the caller and neither
|
||
# group- nor world-writable. Default /tmp passes by virtue of the sticky
|
||
# bit. Group-writable parents are rejected because the parent's group
|
||
# may differ from TMUX_PARTY_GROUP, in that case its members would be
|
||
# outside our trust perimeter yet able to rename or unlink our per-party
|
||
# directory.
|
||
validate_socket_dir_parent() {
|
||
p="$PARTY_SOCKET_DIR"
|
||
# Whitespace in the parent breaks every `for rec in $(roster_list)`
|
||
# walker and the unquoted paths embedded in tmux hook/status-right
|
||
# strings. Refuse at host time, where the dir is chosen, instead of
|
||
# corrupting quietly later.
|
||
case "$p" in
|
||
*[[:space:]]*)
|
||
echo "party: PARTY_SOCKET_DIR must not contain whitespace: $p" >&2
|
||
return 1 ;;
|
||
esac
|
||
if [ ! -d "$p" ]; then
|
||
echo "party: PARTY_SOCKET_DIR $p does not exist" >&2
|
||
return 1
|
||
fi
|
||
if [ -k "$p" ]; then
|
||
# Sticky bit only protects entries against non-owners; the parent's
|
||
# OWNER can still rename or unlink our per-party dir. Accept sticky
|
||
# only if the parent is owned by us or by root.
|
||
[ -O "$p" ] && return 0
|
||
# shellcheck disable=SC2012 # ls -ldn is portable; find -printf is not
|
||
p_uid=$(ls -ldn "$p" 2>/dev/null | awk '{print $3; exit}')
|
||
[ "$p_uid" = 0 ] && return 0
|
||
fi
|
||
# Two separate -perm checks: POSIX/BSD find uses `+` for "any of",
|
||
# GNU find uses `/`, but both accept `-mode` ("all of these bits set"),
|
||
# so we ask the question twice instead of OR-ing in one expression.
|
||
if [ -O "$p" ] \
|
||
&& [ -z "$(find "$p" -prune -perm -0002 2>/dev/null)" ] \
|
||
&& [ -z "$(find "$p" -prune -perm -0020 2>/dev/null)" ]; then
|
||
return 0
|
||
fi
|
||
cat >&2 <<EOF
|
||
party: refusing to operate in PARTY_SOCKET_DIR=$p
|
||
Parent must be either:
|
||
(a) sticky (mode +t, like /tmp) AND owned by you ($USER) or root, or
|
||
(b) owned by you ($USER) AND not group/world-writable.
|
||
Set PARTY_SOCKET_DIR to a directory meeting one of these.
|
||
EOF
|
||
return 1
|
||
}
|
||
|
||
# Default name when user runs `party host` with no args. Random hex
|
||
# suffix; no hostname involvement (parties are scoped to the host the
|
||
# script runs on, so the user already knows where they are).
|
||
compute_default_party_name() {
|
||
# 3 random bytes as hex. The `|| ...` can't catch a read failure on
|
||
# a pipeline (its exit status is tr's, not od's), so test the result
|
||
# and fall back to the PID when /dev/urandom gave us nothing.
|
||
rand=$(od -An -N3 -tx1 /dev/urandom 2>/dev/null | tr -d ' ')
|
||
[ -n "$rand" ] || rand=$$
|
||
printf 'party-%s\n' "$rand"
|
||
}
|
||
|
||
# Absolute path to this script, for embedding in tmux server state that
|
||
# outlives this invocation (the status-right #() widget runs with the
|
||
# tmux server's PATH, which need not contain an installed `party`).
|
||
# $0 forms: absolute, take it; relative-with-slash, anchor to cwd;
|
||
# bare word, resolve via PATH, falling back to $0 verbatim. No `--`
|
||
# after command -v/dirname/basename: old illumos userland predates it,
|
||
# and $0 here never starts with '-'.
|
||
party_self_path() {
|
||
case "$0" in
|
||
/*) printf '%s\n' "$0" ;;
|
||
*/*) printf '%s/%s\n' "$(cd "$(dirname "$0")" && pwd)" \
|
||
"$(basename "$0")" ;;
|
||
*) _sp=$(command -v "$0" 2>/dev/null) || _sp="$0"
|
||
# command -v may itself answer relatively (PATH with "." or
|
||
# an empty entry); anchor those to cwd like the */* case.
|
||
case "$_sp" in
|
||
/*) printf '%s\n' "$_sp" ;;
|
||
*) printf '%s/%s\n' "$PWD" "$_sp" ;;
|
||
esac ;;
|
||
esac
|
||
}
|
||
|
||
# After spawning the party server, configure status line and hooks.
|
||
# All commands run against -S "$sock". Every literal name embedded
|
||
# is shell-quoted to survive tmux's parser.
|
||
tmux_party_setup_server() {
|
||
sock="$1" name="$2"
|
||
|
||
# Write a tiny notification helper next to the socket. The leading dot
|
||
# keeps it out of typical `ls` listings. The per-party private dir
|
||
# hosts at most one socket so a fixed name is safe.
|
||
notify_script="${sock%/*}/.party-notify"
|
||
# Two heredocs: the first (expanding) pins the tmux binary the party
|
||
# was hosted with: a bare `tmux` from the server's PATH may be a
|
||
# different, older binary than $PARTY_TMUX, which is the whole reason
|
||
# PARTY_TMUX exists. The second (quoted) is the static body. A
|
||
# PARTY_TMUX containing a double quote, $, or backslash would break
|
||
# the pin; that's accepted: it's the host's own env var on their
|
||
# own party.
|
||
cat > "$notify_script" <<EOF
|
||
#!/bin/sh
|
||
tmux="$PARTY_TMUX"
|
||
EOF
|
||
cat >> "$notify_script" <<'EOF'
|
||
# Auto-generated by party. Fan a display-message out to every client
|
||
# of the party server. Args: $1 = socket, $2 = client_user, $3 = "joined"|"left"
|
||
sock="$1"; user="$2"; verb="$3"
|
||
"$tmux" -S "$sock" list-clients -F '#{client_name}' 2>/dev/null \
|
||
| while read -r c; do
|
||
"$tmux" -S "$sock" display-message -c "$c" "$user $verb the party" 2>/dev/null || :
|
||
done
|
||
EOF
|
||
chmod 0755 "$notify_script"
|
||
# Match the rest of the per-party dir's group ownership so guests'
|
||
# debugging (`ls -l` inside a party dir they can traverse) doesn't
|
||
# surface this one file as the odd `:other` outlier. The script is
|
||
# invoked by tmux run-shell as the host, so guests don't need
|
||
# execute on it; setting the group is purely for consistency.
|
||
chgrp "$TMUX_PARTY_GROUP" "$notify_script"
|
||
|
||
"$PARTY_TMUX" -S "$sock" set-option -s exit-empty on
|
||
party_self=$(party_self_path)
|
||
# The #() body is run by the tmux server via sh, so the embedded
|
||
# paths are single-quoted: a path containing a single quote, a `)`,
|
||
# or `#{` (tmux's #() parser ends at the first unescaped `)`) is
|
||
# accepted breakage (host's own install path).
|
||
"$PARTY_TMUX" -S "$sock" set-option -g status-right \
|
||
"party: #('$party_self' who --short --socket '$sock' 2>/dev/null)"
|
||
# Join/leave broadcast. Hook bodies fan a display-message out to
|
||
# every attached client of THIS server via the notify helper script.
|
||
# tmux substitutes #{client_user} before running the shell command.
|
||
"$PARTY_TMUX" -S "$sock" set-hook -g client-attached \
|
||
"run-shell '$notify_script $sock #{client_user} joined'"
|
||
"$PARTY_TMUX" -S "$sock" set-hook -g client-detached \
|
||
"run-shell '$notify_script $sock #{client_user} left'"
|
||
}
|
||
|
||
# Find the live roster record for a party named "$name". Searches across
|
||
# all hosts. On match, sets RR_*. Returns 0 on found, 1 on not found,
|
||
# 2 on ambiguous (same name from multiple hosts).
|
||
find_party_by_name() {
|
||
target="$1"
|
||
set --
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
[ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
|
||
[ "$RR_PARTY_NAME" = "$target" ] && set -- "$@" "$rec"
|
||
done
|
||
case $# in
|
||
0) return 1 ;;
|
||
1) roster_read "$1"; return 0 ;;
|
||
*) echo "party: '$target' is ambiguous (multiple hosts running it)" >&2
|
||
return 2 ;;
|
||
esac
|
||
}
|
||
|
||
# Pick the single live party, or run a numbered prompt.
|
||
pick_live_party() {
|
||
set --
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
[ "$(party_conn_state "$RR_SOCKET")" = dead ] && continue
|
||
set -- "$@" "$rec"
|
||
done
|
||
case $# in
|
||
0) echo "party: no parties found on this host." >&2; return 1 ;;
|
||
1) roster_read "$1"; return 0 ;;
|
||
esac
|
||
# 2+ parties need the interactive picker; without a tty read would
|
||
# EOF and set -e would kill us with no message.
|
||
if [ ! -t 0 ]; then
|
||
echo "party: multiple parties running; pass a name (see 'party list')." >&2
|
||
return 1
|
||
fi
|
||
i=0
|
||
for r in "$@"; do
|
||
i=$((i+1))
|
||
roster_read "$r"
|
||
printf '%d) %s, %s\n' "$i" "$RR_HOST_USER" "$RR_PARTY_NAME" >&2
|
||
done
|
||
printf 'Choose: ' >&2
|
||
read -r pick
|
||
case "$pick" in ''|*[!0-9]*)
|
||
echo "party: invalid choice" >&2; return 1 ;;
|
||
esac
|
||
if [ "$pick" -lt 1 ] || [ "$pick" -gt $# ]; then
|
||
echo "party: out of range" >&2; return 1
|
||
fi
|
||
eval "roster_read \"\$$pick\""
|
||
}
|
||
|
||
# Subcommand dispatch
|
||
# ===================
|
||
|
||
cmd_host() {
|
||
name=
|
||
group_arg=
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--group)
|
||
[ $# -ge 2 ] || { echo "party host: --group needs an argument" >&2; exit 2; }
|
||
group_arg="$2"; shift 2 ;;
|
||
--group=*) group_arg="${1#--group=}"; shift ;;
|
||
-h|--help)
|
||
cat <<'EOF'
|
||
Usage: party host [name] [--group <name>]
|
||
name Party name (default: random).
|
||
--group <g> Group used to gate this party. The host must be a
|
||
member; only members can discover or join. Precedence:
|
||
--group flag > $TMUX_PARTY_GROUP env > default 'party'.
|
||
Pick any existing group (e.g. 'staff', 'users', 'wheel');
|
||
no install-time setup needed.
|
||
EOF
|
||
exit 0 ;;
|
||
-*)
|
||
echo "party host: unknown flag '$1'" >&2; exit 2 ;;
|
||
*)
|
||
if [ -z "$name" ]; then name="$1"; shift
|
||
else echo "party host: unexpected arg '$1'" >&2; exit 2; fi
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Group precedence: --group flag > $TMUX_PARTY_GROUP env > default
|
||
# 'party' (set by the env-defaults block at the top of the file).
|
||
# The chosen group becomes the per-party perimeter: chgrp on the
|
||
# private dir, on the socket, and on the roster. No shared install
|
||
# directory, pick any existing group the host is in.
|
||
if [ -n "$group_arg" ]; then
|
||
TMUX_PARTY_GROUP="$group_arg"
|
||
export TMUX_PARTY_GROUP
|
||
fi
|
||
|
||
require_caller_in_group
|
||
validate_socket_dir_parent || exit 1
|
||
|
||
# A trustworthy parent isn't necessarily a discoverable parent. With
|
||
# mode 0700, hosting and inviting succeed but no other user can
|
||
# traverse PARTY_SOCKET_DIR to glob for parties, `party join` and
|
||
# `party list` will report not-found for everyone but the host.
|
||
# Warn so the user notices before sending invites. Octal 0050 = group
|
||
# r+x; 0005 = other r+x. Non-empty `find -prune -perm -<bits>` means
|
||
# those bits are set on the parent.
|
||
#
|
||
# Known blind spot (WONTFIX for now): we check that *some* group bits
|
||
# are set, not that the parent's group matches TMUX_PARTY_GROUP. With
|
||
# PARTY_SOCKET_DIR mode 0750 owned by user:wheel and TMUX_PARTY_GROUP=party,
|
||
# this branch is skipped (group r+x is set, for wheel members), but
|
||
# party-group members who aren't in wheel still can't traverse. The
|
||
# mismatch self-announces on the first failed `party join`, and a
|
||
# portable name→gid map is awkward in POSIX shell (no getent on macOS,
|
||
# stat flags split BSD/Linux). Default PARTY_SOCKET_DIR=/tmp (1777)
|
||
# never trips this; only custom dirs with restrictive group perms do.
|
||
if [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0050 2>/dev/null)" ] \
|
||
&& [ -z "$(find "$PARTY_SOCKET_DIR" -prune -perm -0005 2>/dev/null)" ]; then
|
||
cat >&2 <<EOF
|
||
party host: warning, PARTY_SOCKET_DIR=$PARTY_SOCKET_DIR is not readable
|
||
or searchable by group/other. Guests will not be able to discover this
|
||
party (party join / party list will report not-found). To make it
|
||
discoverable: chmod g+rx $PARTY_SOCKET_DIR and chgrp $TMUX_PARTY_GROUP
|
||
$PARTY_SOCKET_DIR, or use a parent like /tmp.
|
||
EOF
|
||
fi
|
||
|
||
[ -z "$name" ] && name=$(compute_default_party_name)
|
||
validate_party_name "$name" || exit $?
|
||
|
||
party_dir=$(party_dir_path "$USER" "$name")
|
||
sock="$party_dir/sock"
|
||
rec="$party_dir/roster"
|
||
|
||
# AF_UNIX sun_path is 108 bytes on Linux/illumos and 104 on the BSDs
|
||
# (incl. macOS). The validator caps the name at 63, but the per-party
|
||
# dir adds `party-USER:NAME.d/sock` overhead on top of PARTY_SOCKET_DIR:
|
||
# with a 32-byte USER and a long name this overflows. Bail before
|
||
# bind(2) returns ENAMETOOLONG via tmux. 100 leaves headroom on the
|
||
# tightest platform (104 - NUL - small buffer).
|
||
if [ "${#sock}" -gt 100 ]; then
|
||
cat >&2 <<EOF
|
||
party: socket path too long (${#sock} bytes; AF_UNIX sun_path max ~104).
|
||
$sock
|
||
Try a shorter party name, a shorter PARTY_SOCKET_DIR, or both.
|
||
EOF
|
||
exit 1
|
||
fi
|
||
|
||
# Pre-mkdir trust + liveness checks. We diagnose two cases here for a
|
||
# better error message; everything else (including stale leftovers
|
||
# from crashed prior runs) falls through to the mkdir lock below.
|
||
if [ -L "$party_dir" ] || { [ -e "$party_dir" ] && [ ! -O "$party_dir" ]; }; then
|
||
echo "party: $party_dir exists and is not a directory you own" >&2
|
||
exit 1
|
||
fi
|
||
if [ -d "$party_dir" ] && [ -f "$rec" ]; then
|
||
roster_read "$rec" 2>/dev/null || true
|
||
if [ -n "${RR_SOCKET:-}" ] \
|
||
&& [ "$(party_conn_state "$RR_SOCKET")" != dead ]; then
|
||
echo "party: '$name' is already running" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# mkdir is the atomic acquisition primitive. POSIX mkdir(2) on an
|
||
# existing path returns EEXIST without modifying anything, so it
|
||
# serializes concurrent `party host <same-name>` cleanly: exactly one
|
||
# process owns the directory, the rest see EEXIST and bail.
|
||
#
|
||
# We deliberately do NOT auto-clean leftover dirs (e.g. from a prior
|
||
# SIGKILLed cmd_host that couldn't run its rollback trap). Auto-cleanup
|
||
# races with a concurrent in-flight setup: two processes both seeing a
|
||
# stale dir would both rm -rf it, and the loser's late rm -rf would
|
||
# wipe the winner's freshly-created replacement.
|
||
# The recovery path for genuine crash leftovers is a one-shot manual
|
||
# `rm -rf $party_dir` followed by retry; the error message says so.
|
||
if ! mkdir -m 0700 "$party_dir" 2>/dev/null; then
|
||
cat >&2 <<EOF
|
||
party: $party_dir already exists. Either another host is starting '$name'
|
||
right now, or a prior crashed attempt left this directory behind. If no
|
||
other host is in flight, remove the directory and retry:
|
||
rm -rf $party_dir
|
||
EOF
|
||
exit 1
|
||
fi
|
||
|
||
# Catches symlink-swap between mkdir and use on non-sticky parents.
|
||
# We own the dir at this point, so it's safe to rm -rf on failure.
|
||
if [ -L "$party_dir" ] || [ ! -O "$party_dir" ]; then
|
||
rm -rf "$party_dir"
|
||
echo "party: $party_dir failed post-mkdir trust check" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Arm rollback ONLY after mkdir succeeded. Earlier (before mkdir),
|
||
# arming the trap would let a concurrent host attempt that lost the
|
||
# mkdir race tear down the winner's directory and tmux server via
|
||
# set -e + EXIT trap. Now the trap fires only on resources this
|
||
# process actually created. Disarmed once roster_write commits.
|
||
trap '
|
||
"$PARTY_TMUX" -S "$sock" kill-server 2>/dev/null || true
|
||
rm -rf "$party_dir"
|
||
' EXIT INT TERM HUP
|
||
|
||
chgrp "$TMUX_PARTY_GROUP" "$party_dir"
|
||
chmod 0750 "$party_dir"
|
||
|
||
# The per-party dir is now group-traversable (0750), but the tmux
|
||
# socket from `new-session` below lands at 0600, tmux's server.c
|
||
# calls umask(S_IXUSR|S_IRWXG|S_IRWXO) before bind(2), so group/other
|
||
# bits are stripped regardless of our umask or process group. Group
|
||
# members can stat the socket but cannot connect() until
|
||
# apply_party_perms_file widens it AFTER server-access -aw runs.
|
||
"$PARTY_TMUX" -S "$sock" new-session -d -s "$name"
|
||
|
||
tmux_party_setup_server "$sock" "$name"
|
||
|
||
# Auth gate FIRST, server-access runs BEFORE we widen FS perms.
|
||
# This closes the connect-before-allowlist race: without this order,
|
||
# a group member could connect() the socket between chmod g+rw and
|
||
# server-access -a, and tmux would honor an in-flight connection
|
||
# that wasn't yet on the allowlist.
|
||
|
||
# Capability probe. server-access requires tmux ≥ 3.3; without it
|
||
# the auth gate doesn't exist and the FS gate alone would let any
|
||
# group member attach. README states the version requirement, but
|
||
# silently swallowing errors here would degrade the gate to nothing
|
||
# under version skew. Refuse to host
|
||
# rather than ship an unenforced auth layer; the rollback trap
|
||
# tears down the dedicated tmux server we just started.
|
||
if ! "$PARTY_TMUX" -S "$sock" server-access -l >/dev/null 2>&1; then
|
||
ver=$("$PARTY_TMUX" -V 2>/dev/null || echo "?")
|
||
echo "party: tmux ($ver) does not support 'server-access' (requires tmux ≥ 3.3)." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Add host to allowlist. tmux refuses to modify the server owner's
|
||
# own ACL entry ("owns the server, can't change access"), the owner
|
||
# has implicit write access, so that specific message is benign
|
||
# idempotency. Anything else is a real failure: rollback fires.
|
||
err=$("$PARTY_TMUX" -S "$sock" server-access -aw "$USER" 2>&1) || true
|
||
case "$err" in
|
||
'' | *"owns the server"* | *"can't change access"* ) ;;
|
||
*) echo "party: server-access -aw $USER failed: $err" >&2; exit 1 ;;
|
||
esac
|
||
|
||
# FS gate AFTER the access list is populated. The per-party private
|
||
# directory was already chgrp'd + chmod 0750 above, so only the
|
||
# socket itself needs widening.
|
||
apply_party_perms_file "$sock"
|
||
|
||
pid=$("$PARTY_TMUX" -S "$sock" display-message -p '#{pid}')
|
||
|
||
roster_write "$rec" \
|
||
HOST_USER "$USER" \
|
||
PARTY_NAME "$name" \
|
||
SOCKET "$sock" \
|
||
SERVER_PID "$pid" \
|
||
GROUP "$TMUX_PARTY_GROUP" \
|
||
CREATED "$(iso_now)"
|
||
|
||
# Party committed. Disarm rollback, from here on, a failure (e.g.
|
||
# chgrp on the roster) leaves a fully-formed party that the host can
|
||
# tear down explicitly with `party close`. Tearing it down on the
|
||
# caller's behalf for a stray permission warning would be worse.
|
||
trap - EXIT INT TERM HUP
|
||
|
||
# Roster: chgrp only. roster_write already left mode 0640 (g+r, no g+w).
|
||
# Guests read records to discover/join; only the host writes them. Keeping
|
||
# them non-group-writable means a tampered SOCKET=... can't redirect
|
||
# cmd_close's destructive cleanup at peer-supplied paths.
|
||
chgrp "$TMUX_PARTY_GROUP" "$rec"
|
||
|
||
if [ -z "${TMUX:-}" ] && [ -t 1 ]; then
|
||
echo "Party '$name' started. Attaching..."
|
||
exec "$PARTY_TMUX" -S "$sock" attach -t "$name"
|
||
fi
|
||
echo "Party '$name' started. Attach from a fresh shell with: party join $name"
|
||
}
|
||
|
||
cmd_close() {
|
||
name=
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
-h|--help)
|
||
echo "Usage: party close [name]"; exit 0 ;;
|
||
-*) echo "party close: unknown flag '$1'" >&2; exit 2 ;;
|
||
*)
|
||
if [ -z "$name" ]; then name="$1"; shift
|
||
else echo "party close: unexpected arg '$1'" >&2; exit 2; fi
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [ -z "$name" ]; then
|
||
# No name → close the only party we host, if exactly one.
|
||
# Filter forged dirs (symlinks or non-caller-owned) before
|
||
# trusting HOST_USER so another local user can't seed a fake
|
||
# party-$USER:junk.d/roster and turn this into a "multiple
|
||
# parties" DoS.
|
||
set --
|
||
for rec in $(roster_list); do
|
||
d="${rec%/roster}"
|
||
[ -L "$d" ] && continue
|
||
[ -O "$d" ] || continue
|
||
roster_read "$rec" || continue
|
||
[ "$RR_HOST_USER" = "$USER" ] || continue
|
||
set -- "$@" "$RR_PARTY_NAME"
|
||
done
|
||
case $# in
|
||
0) echo "party close: you are not hosting any party." >&2; exit 1 ;;
|
||
1) name="$1" ;;
|
||
*) echo "party close: you host multiple parties; pass a name:" >&2
|
||
echo " $*" >&2; exit 1 ;;
|
||
esac
|
||
fi
|
||
|
||
# Parity with cmd_host's validate_party_name call: reject path
|
||
# separators, whitespace, shell metas, oversized input before path
|
||
# derivation.
|
||
# Hygiene, not the security gate; [ -O ] below is.
|
||
validate_party_name "$name" || exit $?
|
||
|
||
expected_dir=$(party_dir_path "$USER" "$name")
|
||
expected_sock="$expected_dir/sock"
|
||
rec="$expected_dir/roster"
|
||
|
||
# Refuse before any tmux work if the per-party dir isn't unambiguously
|
||
# ours. With a sticky parent (e.g. /tmp), another local user can
|
||
# pre-create party-$USER:$name.d as a symlink before we ever host;
|
||
# without this gate, kill-server would fire against a forged socket
|
||
# path and rm -rf would chase the symlink.
|
||
if [ -L "$expected_dir" ]; then
|
||
echo "party close: $expected_dir is a symlink; refusing." >&2
|
||
exit 1
|
||
fi
|
||
if [ ! -d "$expected_dir" ]; then
|
||
echo "party close: no party named '$name'." >&2; exit 1
|
||
fi
|
||
if [ ! -O "$expected_dir" ]; then
|
||
echo "party close: $expected_dir is not yours; refusing." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Trust anchor (deliberate): paths are derived
|
||
# from $USER + $name and gated on [ -L ]/[ -O ]. mkdir(2) sets owner
|
||
# to caller's euid; chown requires root; so a same-group attacker
|
||
# can't forge a dir that satisfies [ -O ] for our $USER. We do NOT
|
||
# consult the roster file here; file content is advisory in this
|
||
# codebase, never trusted for destructive operations.
|
||
# PARTY_SOCKET_DIR is caller-controlled env, not a privilege
|
||
# boundary, so we don't canonicalize it.
|
||
"$PARTY_TMUX" -S "$expected_sock" kill-server 2>/dev/null || true
|
||
rm -rf "$expected_dir"
|
||
echo "Party '$name' closed."
|
||
}
|
||
cmd_join() {
|
||
passive=0
|
||
name=
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--passive) passive=1; shift ;;
|
||
-h|--help)
|
||
cat <<'EOF'
|
||
Usage: party join [name] [--passive]
|
||
name Party to join. Auto-picks if exactly one is running.
|
||
--passive Attach read-only to the host's session (mirrored view)
|
||
rather than your own guest session. Switch later with
|
||
`party role` (note: role changes are session-level, not a
|
||
read-only boundary, see `party role --help`).
|
||
Invitees added with `party invite -r` are read-only and
|
||
always join passive (tmux refuses session creation from
|
||
read-only clients).
|
||
EOF
|
||
exit 0 ;;
|
||
-*) echo "party join: unknown flag '$1'" >&2; exit 2 ;;
|
||
*)
|
||
if [ -z "$name" ]; then name="$1"; shift
|
||
else echo "party join: unexpected arg '$1'" >&2; exit 2; fi
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [ -n "${TMUX:-}" ]; then
|
||
echo "party join: detach from your current tmux first." >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [ -n "$name" ]; then
|
||
# Capture rc explicitly: under set -e a bare `find_party_by_name`
|
||
# returning 1 or 2 would exit before the case ran, swallowing the
|
||
# error message.
|
||
find_party_by_name "$name" && rc=0 || rc=$?
|
||
case $rc in
|
||
0) ;;
|
||
1) echo "party: '$name' not found." >&2; exit 1 ;;
|
||
2) exit 1 ;; # find_party_by_name already printed the ambiguity hint
|
||
esac
|
||
else
|
||
pick_live_party || exit 1
|
||
fi
|
||
|
||
# Group gate. Check membership against the party's *recorded* group
|
||
# (RR_GROUP), the host may have hosted under --group <other>. The
|
||
# FS perimeter on the per-party dir already blocks non-group readers
|
||
# at roster_list; this preflight just turns that into a friendlier
|
||
# error than "party not found" when membership is the actual cause.
|
||
party_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
||
if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$party_group"; then
|
||
cat >&2 <<EOF
|
||
party: '$RR_PARTY_NAME' uses group '$party_group'; you ($USER) are not a member.
|
||
|
||
Ask the host or your sysadmin to add you, then log out and back in.
|
||
EOF
|
||
exit 1
|
||
fi
|
||
|
||
# Auth gate. The joiner must already be on the access list (added
|
||
# by the host via `party invite`). Pre-flight here so the user gets
|
||
# a helpful message instead of an opaque tmux refusal. For an
|
||
# unauthorized caller server-access -l prints its refusal on stderr
|
||
# with EMPTY stdout (and exit 0, see party_conn_state), so the awk
|
||
# match comes up empty and we land in the exit-13 branch.
|
||
acl_line=$("$PARTY_TMUX" -S "$RR_SOCKET" server-access -l 2>/dev/null \
|
||
| awk -v u="$USER" '$1 == u { print; exit }')
|
||
if [ -z "$acl_line" ]; then
|
||
echo "party: '$RR_PARTY_NAME', ask $RR_HOST_USER to invite you." >&2
|
||
exit 13
|
||
fi
|
||
# Read-only invitees cannot create the guest session: tmux refuses
|
||
# with "client is read-only" (verified 3.3a/3.5a), which would kill
|
||
# an active join mid-new-session under set -eu. Detect the R flag
|
||
# on our own ACL line and take the mirrored view instead. Flag
|
||
# format is "user (R)" on 3.3–3.5 and "user (U,R)" on newer tmux;
|
||
# both end the flag list in "R)".
|
||
case "$acl_line" in
|
||
*"R)"*)
|
||
if [ "$passive" != 1 ]; then
|
||
echo "party: you are read-only on '$RR_PARTY_NAME'; joining passive (mirrored view)." >&2
|
||
passive=1
|
||
fi ;;
|
||
esac
|
||
|
||
if [ "$passive" = 1 ]; then
|
||
# Mirror the host's pane focus, read-only at the tmux client
|
||
# layer. `-r` is the watcher boundary on the bootstrap path: a
|
||
# write-capable invitee who chose --passive cannot send keys
|
||
# into the host's panes regardless of their server-access ACL
|
||
# entry. Role switches done later via `party role` do NOT carry
|
||
# this flag (tmux switch-client cannot set read-only state),
|
||
# so role-switching is documented as session-level only, not
|
||
# a watcher boundary.
|
||
set -- attach-session -r -t "$RR_PARTY_NAME"
|
||
else
|
||
# Active: own session group with an independent window cursor.
|
||
# An active guest session survives a client detach, so create it
|
||
# only if it isn't already there, a plain new-session on re-join
|
||
# would hit "duplicate session" and abort under set -e.
|
||
#
|
||
# Reusing by name is an accepted insider risk, not a defended one:
|
||
# any write-capable invitee can pre-create a session called
|
||
# __party_guest_$USER (and even group it to the party), so on a
|
||
# shared server no session name is unforgeable. We trust invitees
|
||
# by design, that's the whole model. A read-only invitee (-r)
|
||
# can't create sessions, so it's not exposed. See README §Trust
|
||
# model.
|
||
guest="__party_guest_$USER"
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "$guest" 2>/dev/null \
|
||
|| "$PARTY_TMUX" -S "$RR_SOCKET" new-session -d -t "$RR_PARTY_NAME" -s "$guest"
|
||
set -- attach-session -t "$guest"
|
||
fi
|
||
|
||
if [ "${PARTY_DRY_RUN:-0}" = 1 ]; then
|
||
echo "$PARTY_TMUX -S $RR_SOCKET $*"
|
||
return 0
|
||
fi
|
||
# exec with discrete, quoted args: the old flat-string + unquoted
|
||
# `exec $attach_cmd` word-split on any whitespace in PARTY_TMUX or
|
||
# the socket path.
|
||
exec "$PARTY_TMUX" -S "$RR_SOCKET" "$@"
|
||
}
|
||
cmd_leave() {
|
||
case "${1:-}" in
|
||
-h|--help) echo "Usage: party leave"; exit 0 ;;
|
||
esac
|
||
|
||
# Walk every live party; for each one we have any presence on
|
||
# (either via __party_guest_$USER or as a client of the host's
|
||
# session in passive mode), kill our guest session and detach
|
||
# all our clients.
|
||
found=0
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
# Presence checks below (has-session, list-clients) are only
|
||
# meaningful on a server that accepted our connection: for an
|
||
# unauthorized caller has-session answers rc 0 for ANY target,
|
||
# which made leave "succeed" against parties we never joined.
|
||
[ "$(party_conn_state "$RR_SOCKET")" = ok ] || continue
|
||
attached=0
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" has-session -t "__party_guest_$USER" 2>/dev/null \
|
||
&& attached=1
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
|
||
| grep -qx "$USER" && attached=1
|
||
[ "$attached" = 1 ] || continue
|
||
_disconnect_user "$USER"
|
||
found=$((found+1))
|
||
done
|
||
|
||
[ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; }
|
||
}
|
||
# Resolve the party the caller hosts. If exactly one matches, sets RR_*;
|
||
# if zero or multiple, error. Argument: optional party name to disambiguate.
|
||
resolve_authoritative_party() {
|
||
target="$1"
|
||
set --
|
||
for rec in $(roster_list); do
|
||
# Host-only trust: gate before reading the roster so a forged
|
||
# party-$USER:fake.d planted by another local user (symlink, or
|
||
# a real dir owned by them) cannot drive HOST_USER=$USER through
|
||
# this walker.
|
||
d="${rec%/roster}"
|
||
[ -L "$d" ] && continue
|
||
[ -O "$d" ] || continue
|
||
roster_read "$rec" || continue
|
||
[ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
|
||
[ "$RR_HOST_USER" = "$USER" ] || continue
|
||
[ -z "$target" ] || [ "$target" = "$RR_PARTY_NAME" ] || continue
|
||
set -- "$@" "$rec"
|
||
done
|
||
case $# in
|
||
0) if [ -n "$target" ]; then
|
||
echo "party: '$target' not found, or you are not its host." >&2
|
||
else
|
||
echo "party: you do not host any active party." >&2
|
||
fi
|
||
return 1 ;;
|
||
1) roster_read "$1"; return 0 ;;
|
||
*) echo "party: multiple parties match, pass a name." >&2
|
||
return 1 ;;
|
||
esac
|
||
}
|
||
|
||
cmd_invite() {
|
||
user='' readonly=0 partyname=''
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
-r|--read-only) readonly=1; shift ;;
|
||
--party)
|
||
[ $# -ge 2 ] || { echo "party invite: --party needs an argument" >&2; exit 2; }
|
||
partyname="$2"; shift 2 ;;
|
||
-h|--help)
|
||
echo "Usage: party invite <user> [-r] [--party name]"; exit 0 ;;
|
||
-*) echo "party invite: unknown flag '$1'" >&2; exit 2 ;;
|
||
*)
|
||
if [ -z "$user" ]; then user="$1"; shift
|
||
else echo "party invite: unexpected arg '$1'" >&2; exit 2; fi
|
||
;;
|
||
esac
|
||
done
|
||
|
||
[ -n "$user" ] || { echo "Usage: party invite <user>" >&2; exit 2; }
|
||
user_exists "$user" || { echo "party invite: no such user '$user'" >&2; exit 1; }
|
||
|
||
resolve_authoritative_party "$partyname" || exit 1
|
||
|
||
# The auth gate (server-access) accepts any user, but the FS gate
|
||
# (per-party dir mode 0750, group=$RR_GROUP) won't let a non-member
|
||
# traverse in. Without this warning, the host sees "Invited X" and
|
||
# the guest sees a write(1) ping, but `party join` reports not-found.
|
||
# Warn-and-proceed (rather than hard-fail) so a later usermod -aG
|
||
# doesn't require a re-invite.
|
||
invite_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
||
if ! id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$invite_group"; then
|
||
cat >&2 <<EOF
|
||
party invite: warning, '$user' is not in group '$invite_group'.
|
||
They will not be able to discover or join until added.
|
||
Run: sudo usermod -aG $invite_group $user (and have them relog).
|
||
EOF
|
||
fi
|
||
|
||
if [ "$readonly" = 1 ]; then
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -ar "$user"
|
||
else
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -aw "$user"
|
||
fi
|
||
|
||
# Best-effort guest-side ping. Ignore failure (write(1) may be absent).
|
||
write "$user" 2>/dev/null <<EOF || :
|
||
$USER invited you to a party named '$RR_PARTY_NAME'.
|
||
Run: party join $RR_PARTY_NAME
|
||
EOF
|
||
|
||
echo "Invited $user to '$RR_PARTY_NAME'."
|
||
}
|
||
|
||
# Parse the "<user> [--party <name>]" argv shared by voice/mute/kick/
|
||
# detach. Sets mod_user and mod_party in the calling shell (POSIX sh
|
||
# has no locals; the mod_ prefix keeps them out of callers' way).
|
||
# Exits 2 on usage errors.
|
||
_parse_user_party() {
|
||
label="$1"; shift
|
||
mod_user='' mod_party=''
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--party)
|
||
[ $# -ge 2 ] || { echo "party $label: --party needs an argument" >&2; exit 2; }
|
||
mod_party="$2"; shift 2 ;;
|
||
-h|--help)
|
||
echo "Usage: party $label <user> [--party <name>]"; exit 0 ;;
|
||
-*) echo "party $label: unknown flag '$1'" >&2; exit 2 ;;
|
||
*)
|
||
if [ -z "$mod_user" ]; then mod_user="$1"; shift
|
||
else echo "party $label: unexpected arg '$1'" >&2; exit 2; fi
|
||
;;
|
||
esac
|
||
done
|
||
[ -n "$mod_user" ] || { echo "Usage: party $label <user> [--party <name>]" >&2; exit 2; }
|
||
}
|
||
|
||
# Toggle a user's read/write status. $1 = subcommand label (for usage
|
||
# and the success message), $2 = server-access flag (-w voice, -r mute),
|
||
# $3 = past-participle for the message; the rest is the user's argv.
|
||
_set_user_access() {
|
||
label="$1" flag="$2" verb="$3"; shift 3
|
||
_parse_user_party "$label" "$@"
|
||
resolve_authoritative_party "$mod_party" || exit 1
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" server-access "$flag" "$mod_user"
|
||
echo "$verb $mod_user."
|
||
}
|
||
|
||
cmd_voice() { _set_user_access voice -w Voiced "$@"; }
|
||
cmd_mute() { _set_user_access mute -r Muted "$@"; }
|
||
|
||
# Disconnect every client of $user on the resolved party socket and kill
|
||
# their guest session if present. Used by both cmd_kick and cmd_detach.
|
||
_disconnect_user() {
|
||
user="$1"
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" kill-session -t "__party_guest_$user" 2>/dev/null || true
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_name} #{client_user}' 2>/dev/null \
|
||
| while read -r cname cuser; do
|
||
[ "$cuser" = "$user" ] || continue
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" detach-client -t "$cname" 2>/dev/null || true
|
||
done
|
||
}
|
||
|
||
cmd_kick() {
|
||
_parse_user_party kick "$@"
|
||
resolve_authoritative_party "$mod_party" || exit 1
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" server-access -d "$mod_user" 2>/dev/null || true
|
||
_disconnect_user "$mod_user"
|
||
echo "Kicked $mod_user."
|
||
}
|
||
|
||
cmd_detach() {
|
||
_parse_user_party detach "$@"
|
||
resolve_authoritative_party "$mod_party" || exit 1
|
||
_disconnect_user "$mod_user"
|
||
echo "Detached $mod_user (still on allowlist)."
|
||
}
|
||
|
||
cmd_list() {
|
||
found=0
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
state=$(party_conn_state "$RR_SOCKET")
|
||
[ "$state" = dead ] && continue
|
||
# Output hygiene, NOT a confidentiality control. If inherited ACLs
|
||
# widened the per-party dir past its 0750 / group=RR_GROUP mode bits,
|
||
# roster_read above has ALREADY parsed host/name/group into vars,
|
||
# and a determined reader would just cat the roster file directly,
|
||
# never touching `party list`. So this re-check does not close the
|
||
# metadata leak; it only stops `party list` from DISPLAYING parties
|
||
# whose group the caller isn't in. Confidentiality still rides on
|
||
# the FS perimeter alone.
|
||
list_group="${RR_GROUP:-$TMUX_PARTY_GROUP}"
|
||
id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$list_group" || continue
|
||
# Show the group only when it differs from the env/default, to
|
||
# keep the common case uncluttered.
|
||
group_tag=
|
||
if [ -n "$RR_GROUP" ] && [ "$RR_GROUP" != "$TMUX_PARTY_GROUP" ]; then
|
||
group_tag=" [group=$RR_GROUP]"
|
||
fi
|
||
if [ "$state" = ok ]; then
|
||
attendees=$("$PARTY_TMUX" -S "$RR_SOCKET" list-clients 2>/dev/null | wc -l | tr -d ' ')
|
||
printf '%-12s %-30s %s attendee(s)%s\n' \
|
||
"$RR_HOST_USER" "$RR_PARTY_NAME" "$attendees" "$group_tag"
|
||
else
|
||
# Live server, connection refused: we can't count attendees
|
||
# (list-clients is behind the auth gate: its empty stdout
|
||
# used to render here as a bogus "0 attendee(s)"), but the
|
||
# party is real and the caller can ask for an invite.
|
||
printf '%-12s %-30s invite-only (ask %s)%s\n' \
|
||
"$RR_HOST_USER" "$RR_PARTY_NAME" "$RR_HOST_USER" "$group_tag"
|
||
fi
|
||
found=$((found+1))
|
||
done
|
||
|
||
if [ "$found" -eq 0 ]; then
|
||
echo "no parties found on this host."
|
||
fi
|
||
}
|
||
|
||
cmd_who() {
|
||
short=0 partyname='' sockarg=''
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--short) short=1; shift ;;
|
||
--party)
|
||
[ $# -ge 2 ] || { echo "party who: --party needs an argument" >&2; exit 2; }
|
||
partyname="$2"; shift 2 ;;
|
||
--socket)
|
||
[ $# -ge 2 ] || { echo "party who: --socket needs an argument" >&2; exit 2; }
|
||
sockarg="$2"; shift 2 ;;
|
||
-h|--help)
|
||
echo "Usage: party who [--short] [--party name|--socket path]"
|
||
exit 0 ;;
|
||
-*) echo "party who: unknown flag '$1'" >&2; exit 2 ;;
|
||
*) partyname="$1"; shift ;;
|
||
esac
|
||
done
|
||
|
||
if [ -n "$sockarg" ]; then
|
||
# Status-line widget path: derive host/name from the per-party
|
||
# dir basename ${PARTY_SOCKET_DIR}/party-${USER}:${NAME}.d/sock.
|
||
sock="$sockarg"
|
||
parent="${sock%/*}"; parent="${parent##*/}"
|
||
case "$parent" in
|
||
party-*:*.d) ;;
|
||
*) echo "party who: '$sockarg' is not a party socket" >&2; exit 2 ;;
|
||
esac
|
||
stripped="${parent#party-}"; stripped="${stripped%.d}"
|
||
# Seed RR_HOST_USER / RR_PARTY_NAME from the basename before
|
||
# roster_read so the non-short output path never hits an unbound
|
||
# var under set -u when the roster file is missing or unreadable.
|
||
# roster_read overwrites these with the same values on success.
|
||
RR_HOST_USER="${stripped%%:*}"
|
||
RR_PARTY_NAME="${stripped#*:}"
|
||
rec=$(roster_record_path "$RR_HOST_USER" "$RR_PARTY_NAME")
|
||
roster_read "$rec" 2>/dev/null || true
|
||
elif [ -n "$partyname" ]; then
|
||
find_party_by_name "$partyname" || exit 1
|
||
else
|
||
resolve_authoritative_party "" || exit 1
|
||
fi
|
||
|
||
sock="${sock:-$RR_SOCKET}"
|
||
[ -S "$sock" ] || { echo "party who: socket gone" >&2; exit 1; }
|
||
|
||
invited=$("$PARTY_TMUX" -S "$sock" server-access -l 2>/dev/null \
|
||
| awk '{print $1}' | sort -u)
|
||
attached=$("$PARTY_TMUX" -S "$sock" list-clients -F '#{client_user}' 2>/dev/null \
|
||
| sort -u)
|
||
|
||
if [ "$short" = 1 ]; then
|
||
# shellcheck disable=SC2086 # word-split intentional
|
||
set -- $attached
|
||
nicks=$(printf '%s,' "$@" | sed 's/,$//')
|
||
printf 'party: %d (%s)\n' "$#" "$nicks"
|
||
return
|
||
fi
|
||
|
||
echo "Party '$RR_PARTY_NAME' (host: $RR_HOST_USER)"
|
||
echo "Invited:"
|
||
for u in $invited; do printf ' %s\n' "$u"; done
|
||
echo "Attached:"
|
||
if [ -z "$attached" ]; then echo " (none)"
|
||
else for u in $attached; do printf ' %s\n' "$u"; done; fi
|
||
}
|
||
|
||
cmd_status() {
|
||
case "${1:-}" in
|
||
-h|--help) echo "Usage: party status"; exit 0 ;;
|
||
esac
|
||
|
||
hosting='' joined=''
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
state=$(party_conn_state "$RR_SOCKET")
|
||
[ "$state" = dead ] && continue
|
||
if [ "$RR_HOST_USER" = "$USER" ]; then
|
||
hosting="$hosting $RR_PARTY_NAME"
|
||
fi
|
||
# The joined checks below trust has-session, which lies (rc 0
|
||
# for any target) when the server refused our connection.
|
||
[ "$state" = ok ] || continue
|
||
# Joined covers two cases. Passive: we have a client attached to
|
||
# the host's session, list-clients sees it. Active: we have our
|
||
# own __party_guest_$USER session, which survives a client detach
|
||
# (party leave will still find and clean it up). OR the two so a
|
||
# detached active guest still reports as joined.
|
||
if "$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' \
|
||
2>/dev/null | grep -qx "$USER" \
|
||
|| "$PARTY_TMUX" -S "$RR_SOCKET" has-session \
|
||
-t "__party_guest_$USER" 2>/dev/null
|
||
then
|
||
joined="$joined $RR_PARTY_NAME"
|
||
fi
|
||
done
|
||
[ -n "$hosting$joined" ] || { echo "not in any party."; return 0; }
|
||
if [ -n "$hosting" ]; then echo "hosting:$hosting"; fi
|
||
if [ -n "$joined" ]; then echo "joined: $joined"; fi
|
||
}
|
||
|
||
# Locate the party socket where the calling user has at least one
|
||
# attached client. Sets RR_* on success. Used by cmd_role.
|
||
cmd_role() {
|
||
sub="${1:-}"
|
||
case "$sub" in
|
||
-h|--help)
|
||
cat <<'EOF'
|
||
Usage: party role [active|passive|switch]
|
||
(no arg) Print each of your client sessions and its role.
|
||
active Move all your clients to your own guest session.
|
||
passive Move all your clients to the host's session (mirrored view).
|
||
switch Per-client toggle between active and passive.
|
||
|
||
Note: role changes flip the session your client is attached to, but
|
||
do not change its read-only state. The watcher boundary is set at
|
||
attach time by `party join --passive` (or by `party invite -r`); it
|
||
can't be toggled later via `party role`.
|
||
EOF
|
||
exit 0 ;;
|
||
''|active|passive|switch) ;;
|
||
*) echo "party role: unknown role '$sub'" >&2; exit 2 ;;
|
||
esac
|
||
|
||
# Find the party we should target. When $TMUX is set the caller is
|
||
# currently inside a tmux session, its first comma-separated field
|
||
# is the active socket path, which uniquely identifies the party.
|
||
# Without that, fall back to "any party where the caller has a
|
||
# client" (correct when the caller is attached to exactly one).
|
||
sock=''
|
||
host_sess=''
|
||
if [ -n "${TMUX:-}" ]; then
|
||
tmux_sock="${TMUX%%,*}"
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
[ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
|
||
[ "$RR_SOCKET" = "$tmux_sock" ] || continue
|
||
sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
|
||
done
|
||
fi
|
||
if [ -z "$sock" ]; then
|
||
for rec in $(roster_list); do
|
||
roster_read "$rec" || continue
|
||
[ "$(party_conn_state "$RR_SOCKET")" != dead ] || continue
|
||
"$PARTY_TMUX" -S "$RR_SOCKET" list-clients -F '#{client_user}' 2>/dev/null \
|
||
| grep -qx "$USER" || continue
|
||
sock="$RR_SOCKET"; host_sess="$RR_PARTY_NAME"; break
|
||
done
|
||
fi
|
||
[ -n "$sock" ] || { echo "party role: not attached to any party." >&2; exit 1; }
|
||
guest="__party_guest_$USER"
|
||
|
||
# active and switch may switch a client INTO the guest session; create
|
||
# it on demand. passive and the no-arg print path don't need it.
|
||
case "$sub" in
|
||
active|switch)
|
||
"$PARTY_TMUX" -S "$sock" has-session -t "$guest" 2>/dev/null \
|
||
|| "$PARTY_TMUX" -S "$sock" new-session -d -t "$host_sess" -s "$guest"
|
||
;;
|
||
esac
|
||
|
||
"$PARTY_TMUX" -S "$sock" list-clients \
|
||
-F '#{client_name} #{client_user} #{client_session}' 2>/dev/null \
|
||
| while read -r cname cuser csess; do
|
||
[ "$cuser" = "$USER" ] || continue
|
||
case "$sub" in
|
||
'')
|
||
case "$csess" in
|
||
"$guest") printf '%s: active\n' "$cname" ;;
|
||
"$host_sess") printf '%s: passive\n' "$cname" ;;
|
||
*) printf '%s: %s\n' "$cname" "$csess" ;;
|
||
esac ;;
|
||
active) target="$guest" ;;
|
||
passive) target="$host_sess" ;;
|
||
switch)
|
||
if [ "$csess" = "$host_sess" ]; then target="$guest"
|
||
else target="$host_sess"; fi ;;
|
||
esac
|
||
if [ -n "$sub" ]; then
|
||
"$PARTY_TMUX" -S "$sock" switch-client -c "$cname" -t "$target" 2>/dev/null || true
|
||
fi
|
||
done
|
||
}
|
||
|
||
dispatch() {
|
||
[ $# -eq 0 ] && { usage >&2; exit 2; }
|
||
sub="$1"; shift
|
||
case "$sub" in
|
||
-h|--help) usage; exit 0 ;;
|
||
--version) echo "party $PARTY_VERSION"; exit 0 ;;
|
||
host) cmd_host "$@" ;;
|
||
close) cmd_close "$@" ;;
|
||
join) cmd_join "$@" ;;
|
||
leave) cmd_leave "$@" ;;
|
||
invite) cmd_invite "$@" ;;
|
||
voice|rw) cmd_voice "$@" ;;
|
||
mute|ro) cmd_mute "$@" ;;
|
||
kick) cmd_kick "$@" ;;
|
||
detach) cmd_detach "$@" ;;
|
||
role) cmd_role "$@" ;;
|
||
list) cmd_list "$@" ;;
|
||
who) cmd_who "$@" ;;
|
||
status) cmd_status "$@" ;;
|
||
*)
|
||
echo "party: unknown subcommand '$sub'" >&2
|
||
usage >&2
|
||
exit 2 ;;
|
||
esac
|
||
}
|
||
|
||
# Entrypoint
|
||
# ==========
|
||
|
||
if [ "${__PARTY_LIB_ONLY:-0}" != "1" ]; then
|
||
dispatch "$@"
|
||
fi
|