diff --git a/README.md b/README.md index 3c038c2..b3e1846 100755 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback. - `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки. - `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`. +- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW. - `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap. - `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. - `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. @@ -18,6 +19,7 @@ - `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`). - `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок. - `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server. +- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger. ## Базовый сценарий @@ -49,6 +51,11 @@ - `docs/linux-client.md` - `scripts/install_aw_linux_client.sh` +Для режима “только консоль/ssh” без GUI watcher'ов: + +- `docs/console-ssh-logger.md` +- `scripts/install_aw_console_ssh_logger.sh` + Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают: - `secrets/deploy.secrets.env` diff --git a/docs/console-ssh-logger.md b/docs/console-ssh-logger.md new file mode 100755 index 0000000..dd87fce --- /dev/null +++ b/docs/console-ssh-logger.md @@ -0,0 +1,60 @@ +# Console/SSH logger deployment + +## Назначение + +Этот сценарий логирует только консольные действия и SSH-сессии: + +- команды из `bash_history` (`aw-console-commands_`); +- логины/логауты SSH TTY по `who -u` (`aw-ssh-sessions_`, best-effort). + +GUI watcher'ы (`aw-watcher-window`, `aw-watcher-afk`) не требуются. + +## Установка (user-space) + +```bash +cd /path/to/AWatch-rus +sh ./scripts/install_aw_console_ssh_logger.sh \ + --server-host 10.10.10.13 \ + --server-port 5600 +``` + +## Что создается + +- `~/.local/opt/aw-console-ssh-logger/collector.py` +- `~/.local/opt/aw-console-ssh-logger/config.json` +- `~/.local/bin/aw-console-ssh-logger-start` +- `~/.local/bin/aw-console-ssh-logger-stop` +- `~/.local/bin/aw-console-ssh-logger-status` +- `~/.config/systemd/user/aw-console-ssh-logger.service` +- `~/.local/state/aw-console-ssh-logger/` (state + logs) + +## Shell hooks + +Installer добавляет в `~/.bashrc`: + +- `HISTTIMEFORMAT="%s "` для epoch timestamp; +- `histappend`; +- `PROMPT_COMMAND` с `history -a; history -n;`. + +Это нужно, чтобы команды попадали в `~/.bash_history` сразу после выполнения, а не только при logout. + +## Проверка + +На клиенте: + +```bash +~/.local/bin/aw-console-ssh-logger-status +tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log +``` + +На AW сервере: + +```bash +curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | grep -E '^aw-console-commands_|^aw-ssh-sessions_' +``` + +## Ограничения + +- Источник команд — `bash_history`; если пользователь использует не `bash`, нужен отдельный collector. +- Канал `aw-ssh-sessions_*` зависит от корректного `utmp/who` на хосте и может быть неполным в отдельных окружениях. +- Полные системные audit-события (`execve` всех пользователей) требуют root-level `auditd`/journald integration и в этот user-space сценарий не входят. diff --git a/scripts/install_aw_console_ssh_logger.sh b/scripts/install_aw_console_ssh_logger.sh new file mode 100755 index 0000000..85d9bb8 --- /dev/null +++ b/scripts/install_aw_console_ssh_logger.sh @@ -0,0 +1,369 @@ +#!/usr/bin/env sh +set -eu + +SERVER_HOST="10.10.10.13" +SERVER_PORT="5600" +POLL_INTERVAL="5" +INSTALL_ROOT="${HOME}/.local/opt/aw-console-ssh-logger" +BIN_DIR="${HOME}/.local/bin" +STATE_DIR="${HOME}/.local/state/aw-console-ssh-logger" +LOG_DIR="${HOME}/.local/state/aw-console-ssh-logger/logs" +SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" +BASHRC="${HOME}/.bashrc" +BASH_PROFILE="${HOME}/.bash_profile" + +usage() { + cat <<'EOF' +Usage: install_aw_console_ssh_logger.sh [options] + +Options: + --server-host HOST AW server host (default: 10.10.10.13) + --server-port PORT AW server port (default: 5600) + --poll-interval SEC Poll interval for history/session tracking (default: 5) + -h, --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --server-host) + SERVER_HOST="$2" + shift 2 + ;; + --server-port) + SERVER_PORT="$2" + shift 2 + ;; + --poll-interval) + POLL_INTERVAL="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${SYSTEMD_USER_DIR}" + +write_file() { + target="$1" + mkdir -p "$(dirname "${target}")" + cat > "${target}" +} + +write_file "${INSTALL_ROOT}/config.json" < str: + return dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +class Collector: + def __init__(self, cfg): + self.cfg = cfg + self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0" + self.host = cfg.get("hostname") or socket.gethostname().split(".")[0] + self.user = cfg.get("username") or os.environ.get("USER", "unknown") + self.history_bucket = f"aw-console-commands_{self.host}" + self.ssh_bucket = f"aw-ssh-sessions_{self.host}" + self.history_path = pathlib.Path(cfg["history_file"]).expanduser() + self.state_dir = pathlib.Path(cfg["state_dir"]).expanduser() + self.state_dir.mkdir(parents=True, exist_ok=True) + self.history_state_path = self.state_dir / "history_state.json" + self.ssh_state_path = self.state_dir / "ssh_state.json" + self.history_state = self._read_json(self.history_state_path, {"inode": None, "offset": 0}) + self.ssh_state = self._read_json(self.ssh_state_path, {"active": {}}) + self.poll_interval = max(1, int(cfg.get("poll_interval_seconds", 5))) + self.ensured_buckets = set() + + def _read_json(self, path, default): + if not path.exists(): + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return default + + def _write_json(self, path, payload): + path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8") + + def _heartbeat(self, bucket_id, payload): + self._ensure_bucket(bucket_id) + url = f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime=60" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=10): + return True + except urllib.error.URLError: + return False + + def _ensure_bucket(self, bucket_id): + if bucket_id in self.ensured_buckets: + return True + + if bucket_id == self.history_bucket: + bucket_type = "app.console.command" + elif bucket_id == self.ssh_bucket: + bucket_type = "app.ssh.session" + else: + bucket_type = "app.custom" + + payload = { + "client": "aw-console-ssh-logger", + "type": bucket_type, + "hostname": self.host + } + url = f"{self.server}/buckets/{bucket_id}" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=10): + self.ensured_buckets.add(bucket_id) + return True + except urllib.error.URLError: + return False + + def process_history(self): + if not self.history_path.exists(): + return + + st = self.history_path.stat() + inode = int(st.st_ino) + size = int(st.st_size) + + if self.history_state.get("inode") != inode or int(self.history_state.get("offset", 0)) > size: + self.history_state = {"inode": inode, "offset": 0} + + offset = int(self.history_state.get("offset", 0)) + with self.history_path.open("r", encoding="utf-8", errors="replace") as f: + f.seek(offset) + lines = f.readlines() + new_offset = f.tell() + + pending_ts = None + now_iso = to_iso(time.time()) + for raw in lines: + line = raw.rstrip("\n") + if not line: + continue + if line.startswith("#") and line[1:].isdigit(): + pending_ts = int(line[1:]) + continue + + ts = pending_ts if pending_ts is not None else time.time() + pending_ts = None + event = { + "timestamp": to_iso(ts), + "duration": 0, + "data": { + "source": "bash_history", + "user": self.user, + "host": self.host, + "command": line + } + } + self._heartbeat(self.history_bucket, event) + + self.history_state["inode"] = inode + self.history_state["offset"] = new_offset + self._write_json(self.history_state_path, self.history_state) + + def get_sessions(self): + who_bin = shutil.which("who") or "/usr/bin/who" + try: + out = subprocess.check_output([who_bin, "-u"], text=True, stderr=subprocess.DEVNULL) + except Exception: + return {} + + sessions = {} + for row in out.splitlines(): + parts = row.split() + if len(parts) < 2: + continue + user = parts[0] + tty = parts[1] + if not tty.startswith("pts/"): + continue + sessions[tty] = { + "user": user, + "tty": tty, + "raw": row + } + return sessions + + def process_ssh_sessions(self): + active_prev = dict(self.ssh_state.get("active", {})) + active_now = self.get_sessions() + + for tty, meta in active_now.items(): + if tty in active_prev: + continue + event = { + "timestamp": to_iso(time.time()), + "duration": 0, + "data": { + "source": "who", + "event": "login", + "user": meta["user"], + "tty": tty, + "host": self.host, + "raw": meta["raw"] + } + } + self._heartbeat(self.ssh_bucket, event) + + for tty, meta in active_prev.items(): + if tty in active_now: + continue + event = { + "timestamp": to_iso(time.time()), + "duration": 0, + "data": { + "source": "who", + "event": "logout", + "user": meta.get("user", self.user), + "tty": tty, + "host": self.host, + "raw": meta.get("raw", "") + } + } + self._heartbeat(self.ssh_bucket, event) + + self.ssh_state["active"] = active_now + self._write_json(self.ssh_state_path, self.ssh_state) + + def run(self): + while True: + self.process_history() + self.process_ssh_sessions() + time.sleep(self.poll_interval) + + +def main(): + config_path = pathlib.Path(os.environ.get("AW_CONSOLE_SSH_CONFIG", "~/.local/opt/aw-console-ssh-logger/config.json")).expanduser() + cfg = json.loads(config_path.read_text(encoding="utf-8")) + Collector(cfg).run() + + +if __name__ == "__main__": + main() +EOF +chmod 0755 "${INSTALL_ROOT}/collector.py" + +write_file "${BIN_DIR}/aw-console-ssh-logger-start" </dev/null 2>&1; then + exit 0 +fi +nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 & +EOF +chmod 0755 "${BIN_DIR}/aw-console-ssh-logger-start" + +write_file "${BIN_DIR}/aw-console-ssh-logger-stop" <<'EOF' +#!/usr/bin/env sh +set -eu +pkill -u "$(id -u)" -f "aw-console-ssh-logger/collector.py" || true +EOF +chmod 0755 "${BIN_DIR}/aw-console-ssh-logger-stop" + +write_file "${BIN_DIR}/aw-console-ssh-logger-status" <<'EOF' +#!/usr/bin/env sh +set -eu +pgrep -a -u "$(id -u)" -f "aw-console-ssh-logger/collector.py" || true +EOF +chmod 0755 "${BIN_DIR}/aw-console-ssh-logger-status" + +write_file "${SYSTEMD_USER_DIR}/aw-console-ssh-logger.service" </dev/null; then + cat >> "${BASHRC}" </dev/null; then + cat >> "${BASH_PROFILE}" </dev/null 2>&1; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + systemctl --user enable --now aw-console-ssh-logger.service >/dev/null 2>&1 || true +fi + +"${BIN_DIR}/aw-console-ssh-logger-start" + +echo "Installed AW console/ssh logger" +echo "Server target: ${SERVER_HOST}:${SERVER_PORT}" +echo "Collector: ${INSTALL_ROOT}/collector.py" +echo "State dir: ${STATE_DIR}" +echo "Log file: ${LOG_DIR}/collector.log"