Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The project is intended for alerting and evidence collection on equipment owned
- systemd user services
- Wayland session
- Motion with a V4L2 camera
- swaylock for the current lock lifecycle
- Noctalia v5 for the current Niri lock lifecycle
- Bash scripts

Do not claim support for another distribution, compositor, lock screen, camera stack, or init system until it has been tested and documented.
Expand All @@ -27,7 +27,7 @@ Read `docs/ARCHITECTURE.md` before changing service boundaries.
- `vigilia-motion.service` owns camera monitoring.
- `vigilia-power.service` independently owns AC power events.
- `vigilia-inhibit.service` blocks idle/sleep while armed.
- `vigilia-arm` starts the target and waits for the lock command to exit.
- `vigilia-arm` starts the target and owns the selected lock backend lifecycle.
- `motion-event.sh` and `power-watch.sh` append events and call `notify.sh`.

Keep camera and power monitoring independent. A failure in notification delivery must not prevent local logging.
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Vigilia 是一套面向 Linux 笔记本的轻量级离席警戒工具。布防
- ntfy 文本通知与抓拍附件
- 本地 TSV 事件日志和 systemd journal 双重记录
- 布防时阻止自动休眠
- `swaylock` 解锁后自动撤防
- Noctalia 锁定状态确认后自动撤防,兼容阻塞式锁屏命令
- 不开放 Motion Web 控制或视频流端口

## 威胁模型
Expand All @@ -25,10 +25,10 @@ Vigilia 用于**发现、告警和留证**,不能阻止具备物理访问权
Arch/CachyOS:

```bash
sudo pacman -S motion v4l-utils swaylock curl libnotify
sudo pacman -S motion v4l-utils noctalia curl libnotify
```

基础运行还需要 systemd、Bash、util-linux 和 Linux power_supply 接口;这些通常已随系统安装。
基础运行还需要 systemd、Bash、awk、util-linux 和 Linux power_supply 接口;这些通常已随系统安装。

## 安装

Expand Down Expand Up @@ -136,7 +136,23 @@ vigilia-disarm
vigilia-status
```

默认情况下,`vigilia-arm` 启动监控后运行 `swaylock`;成功解锁时会自动停止所有监控服务。
默认 Noctalia 后端会先请求 `noctalia msg session lock`,再轮询 `noctalia msg status` 的真实 `locked` 状态。只有先观察到锁定、之后观察到解锁,Vigilia 才会停止监控并记录 `DISARMED reason=unlock backend=noctalia`;IPC 请求命令自行退出绝不等同于解锁。

现有用户若已经在 `vigilia.env` 设置了 `LOCK_COMMAND`,安装器会保留它,并自动继续使用旧的阻塞命令后端。阻塞命令必须在真实解锁前保持运行;若它过早退出,Vigilia 会记录 `LOCK_FAILED` 并安全撤防,而不是伪造解锁。

可显式选择后端:

```bash
# Niri + Noctalia v5(默认)
LOCK_BACKEND="noctalia"
NOCTALIA_COMMAND="noctalia"

# 保留给仍使用前台阻塞锁屏程序的用户
LOCK_BACKEND="command"
LOCK_COMMAND="swaylock"
```

锁定获取超时、Noctalia 状态不可用或阻塞命令异常退出,都会在 `events.tsv` 与 journal 中写入 `LOCK_FAILED`。这些都不是成功解锁。

### Niri 快捷键

Expand Down
193 changes: 171 additions & 22 deletions bin/vigilia-arm
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}/vigilia"
STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/vigilia"
ENV_FILE="$CONFIG_HOME/vigilia.env"
LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.lock"
ARM_PID_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.pid"
ARM_START_TIME_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.start-time"
EVENT_LOG="$STATE_HOME/events.tsv"

mkdir -p "$STATE_HOME"
Expand All @@ -15,48 +17,195 @@ if [[ -r "$ENV_FILE" ]]; then
source "$ENV_FILE"
fi

if [[ -n "${LOCK_BACKEND:-}" ]]; then
LOCK_BACKEND="$LOCK_BACKEND"
elif [[ -n "${LOCK_COMMAND:-}" ]]; then
# Existing pre-Noctalia configurations explicitly chose a blocking command.
LOCK_BACKEND="command"
else
LOCK_BACKEND="noctalia"
fi
LOCK_COMMAND="${LOCK_COMMAND:-swaylock}"
LOCK_ACQUIRE_TIMEOUT_SECONDS="${LOCK_ACQUIRE_TIMEOUT_SECONDS:-15}"
LOCK_POLL_INTERVAL_SECONDS="${LOCK_POLL_INTERVAL_SECONDS:-1}"
LOCK_COMMAND_ACQUIRE_DELAY_SECONDS="${LOCK_COMMAND_ACQUIRE_DELAY_SECONDS:-1}"
NOCTALIA_COMMAND="${NOCTALIA_COMMAND:-noctalia}"

record_event() {
local event="$1"
local detail="${2:-}"

printf '%s\t%s\t%s\n' "$(date --iso-8601=seconds)" "$event" "$detail" \
>> "$EVENT_LOG"
logger --tag vigilia -- "$event $detail"
}

notify_armed() {
"$HOME/.local/share/vigilia/bin/notify.sh" text \
"Vigilia armed" \
"Camera monitoring, power monitoring, and sleep inhibition are active." \
3 >/dev/null 2>&1 || true
}

stop_monitoring() {
systemctl --user stop vigilia.target >/dev/null 2>&1 || true
}

cleanup() {
stop_monitoring
rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE"
}

lock_status_detail() {
local hint="unknown"

if [[ -n "${XDG_SESSION_ID:-}" ]] && command -v loginctl >/dev/null 2>&1; then
hint="$(loginctl show-session "$XDG_SESSION_ID" -p LockedHint --value \
2>/dev/null || true)"
case "$hint" in
yes|no) ;;
*) hint="unknown" ;;
esac
fi

printf 'logind_locked_hint=%s' "$hint"
}

noctalia_locked() {
local status

status="$("$NOCTALIA_COMMAND" msg status 2>/dev/null)" || return 2
grep -Eq '"locked"[[:space:]]*:[[:space:]]*true' <<< "$status"
}

sleep_poll_interval() {
sleep "$LOCK_POLL_INTERVAL_SECONDS"
}

run_noctalia_backend() {
local deadline acquired=0 status status_unavailable=0

if ! "$NOCTALIA_COMMAND" msg session lock >/dev/null 2>&1; then
record_event "LOCK_FAILED" "backend=noctalia reason=lock_request_failed"
return 1
fi

deadline=$((SECONDS + LOCK_ACQUIRE_TIMEOUT_SECONDS))
while ((SECONDS < deadline)); do
if noctalia_locked; then
acquired=1
record_event "LOCK_ACQUIRED" \
"backend=noctalia $(lock_status_detail)"
break
else
status=$?
if [[ "$status" -eq 2 ]]; then
status_unavailable=1
fi
fi
sleep_poll_interval
done

if (( ! acquired )); then
if (( status_unavailable )); then
record_event "LOCK_FAILED" "backend=noctalia reason=status_unavailable"
else
record_event "LOCK_FAILED" "backend=noctalia reason=acquire_timeout"
fi
return 1
fi

while true; do
if noctalia_locked; then
sleep_poll_interval
continue
else
status=$?
if [[ "$status" -eq 2 ]]; then
record_event "LOCK_FAILED" \
"backend=noctalia reason=status_unavailable_after_acquire"
return 1
fi
fi
break
done

record_event "DISARMED" "reason=unlock backend=noctalia $(lock_status_detail)"
return 0
}

run_command_backend() {
local lock_status
local -a lock_argv

read -r -a lock_argv <<< "$LOCK_COMMAND"
"${lock_argv[@]}" &
local lock_pid=$!

sleep "$LOCK_COMMAND_ACQUIRE_DELAY_SECONDS"
if ! kill -0 "$lock_pid" 2>/dev/null; then
wait "$lock_pid" || lock_status=$?
record_event "LOCK_FAILED" \
"backend=command reason=command_exited_before_acquire status=${lock_status:-0}"
return 1
fi

record_event "LOCK_ACQUIRED" "backend=command"
if ! wait "$lock_pid"; then
lock_status=$?
record_event "LOCK_FAILED" \
"backend=command reason=command_exited_nonzero status=$lock_status"
return 1
fi

record_event "DISARMED" "reason=unlock backend=command"
}

exec 9>"$LOCK_FILE"
if ! flock -n 9; then
notify-send "Vigilia" "Already armed or waiting for unlock." 2>/dev/null || true
exit 1
fi

cleanup() {
systemctl --user stop vigilia.target >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
printf '%s\n' "$$" > "$ARM_PID_FILE"
awk '{ print $22 }' "/proc/$$/stat" > "$ARM_START_TIME_FILE"

trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

systemctl --user start vigilia.target

for service in vigilia-motion.service vigilia-power.service; do
if ! systemctl --user is-active --quiet "$service"; then
notify-send -u critical "Vigilia failed to arm" \
"$service did not start." 2>/dev/null || true
record_event "LOCK_FAILED" "backend=$LOCK_BACKEND reason=monitoring_service_inactive service=$service"
exit 1
fi
done

printf '%s\tARMED\tservices=motion,power,inhibit\n' \
"$(date --iso-8601=seconds)" >> "$EVENT_LOG"
logger --tag vigilia -- "ARMED services=motion,power,inhibit"

"$HOME/.local/share/vigilia/bin/notify.sh" text \
"Vigilia armed" \
"Camera monitoring, power monitoring, and sleep inhibition are active." \
3 >/dev/null 2>&1 || true

record_event "ARMED" "services=motion,power,inhibit backend=$LOCK_BACKEND"
notify_armed
notify-send "Vigilia" "Armed. Locking the session." 2>/dev/null || true

LOCK_COMMAND="${LOCK_COMMAND:-swaylock}"
read -r -a lock_argv <<< "$LOCK_COMMAND"
"${lock_argv[@]}"

systemctl --user stop vigilia.target
trap - EXIT
case "$LOCK_BACKEND" in
noctalia)
run_noctalia_backend
;;
command)
run_command_backend
;;
*)
record_event "LOCK_FAILED" \
"backend=$LOCK_BACKEND reason=unsupported_backend"
exit 1
;;
esac

printf '%s\tDISARMED\treason=unlock\n' \
"$(date --iso-8601=seconds)" >> "$EVENT_LOG"
logger --tag vigilia -- "DISARMED reason=unlock"
stop_monitoring
rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE"
trap - EXIT INT TERM

"$HOME/.local/share/vigilia/bin/notify.sh" text \
"Vigilia disarmed" \
Expand Down
15 changes: 15 additions & 0 deletions bin/vigilia-disarm
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@
set -euo pipefail

STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}/vigilia"
ARM_PID_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.pid"
ARM_START_TIME_FILE="${XDG_RUNTIME_DIR:-/tmp}/vigilia-arm.start-time"
mkdir -p "$STATE_HOME"

systemctl --user stop vigilia.target
if [[ -r "$ARM_PID_FILE" && -r "$ARM_START_TIME_FILE" ]]; then
read -r arm_pid < "$ARM_PID_FILE" || arm_pid=""
read -r arm_start_time < "$ARM_START_TIME_FILE" || arm_start_time=""
if [[ "$arm_pid" =~ ^[0-9]+$ && "$arm_start_time" =~ ^[0-9]+$ ]] &&
kill -0 "$arm_pid" 2>/dev/null &&
[[ "$(awk '{ print $22 }' "/proc/$arm_pid/stat" 2>/dev/null || true)" == "$arm_start_time" ]]; then
kill -TERM "$arm_pid" || true
else
rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE"
fi
else
rm -f "$ARM_PID_FILE" "$ARM_START_TIME_FILE"
fi
printf '%s\tDISARMED\treason=manual\n' \
"$(date --iso-8601=seconds)" >> "$STATE_HOME/events.tsv"
logger --tag vigilia -- "DISARMED reason=manual"
Expand Down
11 changes: 10 additions & 1 deletion config/vigilia.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,14 @@ NTFY_URL="https://ntfy.sh"
NTFY_TOPIC=""
NTFY_TOKEN=""

# Command that blocks until the session is unlocked.
# Lock lifecycle backend: noctalia waits for `noctalia msg status` to report a
# real lock and later unlock; command keeps the legacy blocking-command path.
LOCK_BACKEND="noctalia"
NOCTALIA_COMMAND="noctalia"
LOCK_ACQUIRE_TIMEOUT_SECONDS=15
LOCK_POLL_INTERVAL_SECONDS=1

# Used only when LOCK_BACKEND="command". The command must remain running until
# real unlock; a command that exits before acquisition fails closed.
LOCK_COMMAND="swaylock"
LOCK_COMMAND_ACQUIRE_DELAY_SECONDS=1
8 changes: 5 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ vigilia-arm
│ │ └── power-watch.sh → notify.sh
│ └── vigilia-inhibit.service
│ └── systemd-inhibit
└── swaylock
└── unlock → systemd --user stop vigilia.target
└── lock backend
├── Noctalia v5 IPC → observed locked → observed unlocked → stop target
└── blocking command → command exit after acquisition → stop target
```

## Evidence paths
Expand All @@ -25,4 +26,5 @@ vigilia-arm
- Camera and AC power monitoring are independent services so a power event is still recorded when no motion event occurs.
- Web control and live-stream ports are disabled by default.
- The project uses user services and does not require a privileged daemon.
- Unlocking ends the blocking lock command, which lets `vigilia-arm` automatically disarm the target.
- The Noctalia backend treats `noctalia msg session lock` as a request only. It disarms after observing `locked=true` and then `locked=false` through `noctalia msg status`; an unavailable or failed state is recorded as `LOCK_FAILED`, never as unlock.
- The command backend retains blocking-lock compatibility, but a command that exits before its acquisition delay is a failed lock request rather than an unlock.
10 changes: 9 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,13 @@ Record whether power is barrel-jack or USB-C PD. Docking stations and multi-sour

## 6. Lock and disarm lifecycle

Arm with:
Confirm the configured backend before arming:

```bash
grep -E '^(LOCK_BACKEND|LOCK_COMMAND|NOCTALIA_COMMAND)=' ~/.config/vigilia/vigilia.env
```

For Niri + Noctalia v5, use `LOCK_BACKEND="noctalia"` and do not substitute `swaylock`. Arm with:

```bash
vigilia-arm
Expand All @@ -149,6 +155,7 @@ Expected while locked:
- `vigilia.target`, motion, power, and inhibit services are active;
- idle/sleep inhibition is present;
- the camera indicator matches Motion activity.
- for Noctalia, an initial `LOCK_ACQUIRED backend=noctalia` event exists before the unlock; `noctalia msg session lock` returning alone is not evidence of locking.

Unlock normally.

Expand All @@ -157,6 +164,7 @@ Expected after unlock:
- the target and all three services become inactive;
- the camera indicator turns off promptly;
- a `DISARMED` event is recorded;
- for Noctalia, `DISARMED reason=unlock backend=noctalia` occurs only after `LOCK_ACQUIRED`; any `LOCK_FAILED` is a failed/cancelled lifecycle, not a pass;
- a second `vigilia-disarm` is harmless.

Also test explicit disarm from another terminal or trusted remote session while locked.
Expand Down
Loading