#!/usr/bin/env python3 import csv import io import importlib.util import json import os import sys import urllib.request from datetime import datetime, timezone, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse from zoneinfo import ZoneInfo def build_aw_api_base(raw_url): url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") if url.endswith("/api/0"): return url return url + "/api/0" AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") AW = build_aw_api_base(AW_SERVER_URL) REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow")) IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output") DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025" DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30"))) MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300"))) LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0") LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610")) MODULE_PATH = Path(__file__).resolve() def get(u): with urllib.request.urlopen(u, timeout=30) as r: return json.loads(r.read().decode()) def log_warning(message): print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True) def pts(s): return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc) def to_iso_utc(dt): return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") def hhmm(total_seconds): total_seconds = max(0, int(total_seconds)) return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): try: seconds = float(value) except Exception: seconds = float(fallback) if seconds <= 0: seconds = float(fallback) return min(seconds, MAX_SAMPLE_SECONDS) def resolve_host(request_host=None): host = (request_host or DEFAULT_HOST).strip() if not host: host = DEFAULT_HOST return host def get_sessions_bucket_id(host): return f"aw-worktime-sessions_{resolve_host(host)}" def resolve_report_date(day=None, date_text=None): now_local = datetime.now(REPORT_TZ) if date_text: return datetime.strptime(date_text, "%Y-%m-%d").date() if day == "yesterday": return (now_local - timedelta(days=1)).date() return now_local.date() def _is_machine_user(user: str): u = (user or "").strip().lower() return u.endswith("$") or u in {"system", "localservice", "networkservice"} def _is_active_sample(data: dict): state = str(data.get("state") or "").strip().lower() if isinstance(data.get("active"), bool) and data.get("active"): return True if ("актив" in state) or (state == "active"): return True if state == "unknown": try: sid = int(data.get("sessionId")) except Exception: sid = -1 user = str(data.get("username") or "").strip() session_name = str(data.get("sessionName") or "").strip().lower() if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): return True return False def _normalize_user_id(data, host, username): user_id = str(data.get("userId") or "").strip() if user_id: left, sep, right = user_id.partition("\\") if sep and right: return f"{resolve_host(host)}\\{right}" return user_id return f"{resolve_host(host)}\\{username}" def _event_sample_seconds(event, next_same_session_ts=None): data = event.get("data") or {} for key in ("sampleSeconds", "pollSeconds"): value = data.get(key) try: if float(value) > 0: return clamp_seconds(value) except Exception: pass try: duration = float(event.get("duration") or 0.0) except Exception: duration = 0.0 if duration > 0: return clamp_seconds(duration) if next_same_session_ts is not None: delta = (next_same_session_ts - event["_ts"]).total_seconds() if delta > 0: return clamp_seconds(delta) return clamp_seconds(DEFAULT_SAMPLE_SECONDS) def _merge_intervals(intervals): if not intervals: return [] ordered = sorted(intervals, key=lambda item: item[0]) merged = [ordered[0]] for start, end in ordered[1:]: last_start, last_end = merged[-1] if start <= last_end: if end > last_end: merged[-1] = (last_start, end) continue merged.append((start, end)) return merged def aggregate_rows(events, start, end, host): by_user = {} by_identity = {} for event in events: ts = pts(event.get("timestamp")) if ts < start or ts > end: continue data = event.get("data") or {} username = str(data.get("username") or "").strip() if not username: continue session_id = str(data.get("sessionId") or "").strip() or "unknown" event_copy = { "_ts": ts, "data": data, "duration": event.get("duration"), } by_identity.setdefault((username, session_id), []).append(event_copy) for (username, session_id), samples in by_identity.items(): ordered = sorted(samples, key=lambda item: item["_ts"]) for idx, sample in enumerate(ordered): data = sample["data"] active = _is_active_sample(data) next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None sample_seconds = _event_sample_seconds(sample, next_ts) row = by_user.setdefault( username, { "user": username, "user_id": _normalize_user_id(data, host, username), "samples_count": 0, "active_samples": 0, "session_ids": set(), "intervals": [], }, ) row["samples_count"] += 1 row["session_ids"].add(session_id) if active: row["active_samples"] += 1 interval_start = sample["_ts"] interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end + timedelta(seconds=1)) if interval_end > interval_start: row["intervals"].append((interval_start, interval_end)) rows = [] full_range = int((end - start).total_seconds()) + 1 for username in sorted(by_user): row = by_user[username] merged = _merge_intervals(row["intervals"]) active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged)) active_seconds = min(active_seconds, full_range) first_activity = to_iso_utc(merged[0][0]) if merged else "" last_activity = to_iso_utc(merged[-1][1]) if merged else "" rows.append( { "user": row["user"], "user_id": row["user_id"], "active_seconds": active_seconds, "active_hhmm": hhmm(active_seconds), "first_activity": first_activity, "last_activity": last_activity, "idle_seconds": max(0, full_range - active_seconds), "sessions_count": len(row["session_ids"]), "samples_count": row["samples_count"], "active_samples": row["active_samples"], } ) return rows def report_for_date(host, report_date): start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) end_local = start_local + timedelta(days=1) - timedelta(seconds=1) start = start_local.astimezone(timezone.utc) end = end_local.astimezone(timezone.utc) bucket_id = get_sessions_bucket_id(host) try: get(f"{AW}/buckets/{bucket_id}") except Exception: log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") return [] try: events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") except Exception: log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") return [] return aggregate_rows(events, start, end, host) def report_today(host): return report_for_date(host, resolve_report_date()) def report_for_date_fresh(host, report_date): spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.report_for_date(host, report_date) def render_html(rows, host, report_date, selected_day=None): generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") date_local = report_date.strftime("%Y-%m-%d") day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" date_query = f"&date={date_local}" if not day_query else "" trs = [] for row in rows: trs.append( "" f"{row['user']}" f"{row['user_id']}" f"{row['active_hhmm']}" f"{row['active_seconds']}" f"{row['first_activity']}" f"{row['last_activity']}" f"{row['idle_seconds']}" f"{row['sessions_count']}" f"{row['samples_count']}" "" ) if not trs: trs.append('No data for today yet.') return f""" AW-rus Worktime

RDP Worktime Report

Host: {resolve_host(host)} · Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}
{''.join(trs)}
User User ID Active Active sec First activity Last activity Idle sec Sessions Samples
""" class H(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) if parsed.path.startswith("/dlp-ioc/"): name = parsed.path.rsplit("/", 1)[-1] if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: self.send_response(404) self.end_headers() return path = os.path.join(IOC_DIR, name) if not os.path.isfile(path): self.send_response(404) self.end_headers() return with open(path, "rb") as f: data = f.read() if name.endswith(".json"): ctype = "application/json; charset=utf-8" elif name.endswith(".csv"): ctype = "text/csv; charset=utf-8" else: ctype = "text/plain; charset=utf-8" self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return if parsed.path != "/reports/worktime/today": self.send_response(404) self.end_headers() return params = parse_qs(parsed.query, keep_blank_values=False) fmt = "json" if params.get("format", ["json"])[0] == "csv": fmt = "csv" elif params.get("format", ["json"])[0] == "html": fmt = "html" host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) day = params.get("day", ["today"])[0] date_text = params.get("date", [None])[0] report_date = resolve_report_date(day=day, date_text=date_text) rows = report_for_date_fresh(host, report_date) if fmt == "csv": out = io.StringIO() writer = csv.DictWriter( out, fieldnames=[ "user", "user_id", "active_seconds", "active_hhmm", "first_activity", "last_activity", "idle_seconds", "sessions_count", "samples_count", "active_samples", ], ) writer.writeheader() writer.writerows(rows) data = out.getvalue().encode() self.send_response(200) self.send_header("Content-Type", "text/csv; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return if fmt == "html": data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) return obj = { "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "report_timezone": str(REPORT_TZ), "host": host, "report_date": report_date.isoformat(), "bucket_id": get_sessions_bucket_id(host), "rows": rows, } data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def main(): HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever() if __name__ == "__main__": main()