feat: add first page with auth and containers list and agents
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Server
|
||||||
|
JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
|
||||||
|
# Token for the local agent (generate with: uuidgen)
|
||||||
|
LOCAL_AGENT_TOKEN=change-me-to-a-random-token
|
||||||
|
|
||||||
|
# Remote agents only (docker-compose.agent.yml)
|
||||||
|
CONTAINARR_SERVER_URL=http://<your-server-ip>:9090
|
||||||
|
CONTAINARR_AGENT_TOKEN=<token-generated-by-server>
|
||||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# ── Rust (agent/) ─────────────────────────────────────────────────────────────
|
||||||
|
agent/target/
|
||||||
|
|
||||||
|
# ── Go (server/) ──────────────────────────────────────────────────────────────
|
||||||
|
server/vendor/
|
||||||
|
server/*.test
|
||||||
|
server/*.out
|
||||||
|
|
||||||
|
# Generated protobuf (rebuilt with `make proto`)
|
||||||
|
server/internal/proto/
|
||||||
|
|
||||||
|
# ── SvelteKit (web/) ──────────────────────────────────────────────────────────
|
||||||
|
web/node_modules/
|
||||||
|
web/.svelte-kit/
|
||||||
|
web/build/
|
||||||
|
web/dist/
|
||||||
|
web/.env
|
||||||
|
web/.env.*
|
||||||
|
|
||||||
|
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
# ── Données & secrets ─────────────────────────────────────────────────────────
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ── OS ────────────────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ── Éditeurs ──────────────────────────────────────────────────────────────────
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Containarr — Guide Claude Code
|
||||||
|
|
||||||
|
## Rôle de Claude
|
||||||
|
|
||||||
|
Claude est **lead tech** sur ce projet. Il ne code pas directement — il planifie, répartit les tâches entre agents spécialisés, et valide les résultats.
|
||||||
|
|
||||||
|
## Agents spécialisés
|
||||||
|
|
||||||
|
| Agent | Domaine | Répertoire |
|
||||||
|
|-------|---------|-----------|
|
||||||
|
| `containarr-rust-agent` | Rust · bollard · tonic/gRPC · tokio | `agent/` |
|
||||||
|
| `containarr-go-server` | Go · Chi · gRPC gateway · SQLite · JWT | `server/` |
|
||||||
|
| `containarr-svelte-ui` | SvelteKit · Svelte 5 runes · Tailwind · TypeScript | `web/` |
|
||||||
|
|
||||||
|
## Règles strictes
|
||||||
|
|
||||||
|
1. **Jamais de code Rust direct** → déléguer à `containarr-rust-agent`
|
||||||
|
2. **Jamais de code Go direct** → déléguer à `containarr-go-server`
|
||||||
|
3. **Jamais de code SvelteKit/TS direct** → déléguer à `containarr-svelte-ui`
|
||||||
|
4. **Tests obligatoires** : après chaque feature, l'agent doit écrire/mettre à jour les tests et les lancer
|
||||||
|
5. **Validation** : Claude vérifie le résultat avant de clore une tâche
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- `agent/` — Rust (bollard + tonic gRPC) : agent Docker, snapshots toutes les 10s, actions + log streaming
|
||||||
|
- `server/` — Go (Chi + gRPC + SQLite) : tunnel gRPC, API REST, JWT auth, broker WebSocket
|
||||||
|
- `web/` — SvelteKit + Tailwind (thème "abyss" sombre) : dashboard par host, admin, log viewer
|
||||||
|
- `proto/agent/v1/agent.proto` — tunnel bidirectionnel AgentMessage / ServerMessage
|
||||||
|
|
||||||
|
## Commandes clés
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make proto # Regénérer le code Go depuis le .proto
|
||||||
|
make server # Build Go
|
||||||
|
make agent # Build Rust (release)
|
||||||
|
make web # Build SvelteKit
|
||||||
|
make dev-server # Lancer le serveur en dev (go run)
|
||||||
|
make dev-web # Lancer le frontend en dev (vite)
|
||||||
|
make up-server # Docker Compose serveur
|
||||||
|
make up-agent # Docker Compose agent
|
||||||
|
```
|
||||||
44
Makefile
Normal file
44
Makefile
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
.PHONY: proto server agent web dev up-server up-agent
|
||||||
|
|
||||||
|
PROTO_DIR := proto/agent/v1
|
||||||
|
PROTO_OUT := server/internal/proto/agentv1
|
||||||
|
export PATH := $(HOME)/go/bin:/usr/local/go/bin:$(PATH)
|
||||||
|
|
||||||
|
# ── Protobuf codegen (Go side) ────────────────────────────────────────────────
|
||||||
|
proto:
|
||||||
|
mkdir -p $(PROTO_OUT)
|
||||||
|
protoc \
|
||||||
|
--go_out=server --go_opt=module=github.com/containarr/server \
|
||||||
|
--go-grpc_out=server --go-grpc_opt=module=github.com/containarr/server \
|
||||||
|
-I proto \
|
||||||
|
$(PROTO_DIR)/agent.proto
|
||||||
|
|
||||||
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
server:
|
||||||
|
cd server && go build ./...
|
||||||
|
|
||||||
|
agent:
|
||||||
|
cd agent && cargo build --release
|
||||||
|
|
||||||
|
web:
|
||||||
|
cd web && npm run build
|
||||||
|
|
||||||
|
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||||
|
up-server:
|
||||||
|
docker compose -f docker-compose.server.yml up --build -d
|
||||||
|
|
||||||
|
up-agent:
|
||||||
|
docker compose -f docker-compose.agent.yml up --build -d
|
||||||
|
|
||||||
|
down-server:
|
||||||
|
docker compose -f docker-compose.server.yml down
|
||||||
|
|
||||||
|
down-agent:
|
||||||
|
docker compose -f docker-compose.agent.yml down
|
||||||
|
|
||||||
|
# ── Dev (local, no Docker) ───────────────────────────────────────────────────
|
||||||
|
dev-server:
|
||||||
|
cd server && go run ./cmd/server
|
||||||
|
|
||||||
|
dev-web:
|
||||||
|
cd web && npm run dev
|
||||||
2318
agent/Cargo.lock
generated
Normal file
2318
agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
agent/Cargo.toml
Normal file
29
agent/Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "containarr-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.88"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "containarr-agent"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tonic = { version = "0.12", features = ["tls"] }
|
||||||
|
prost = "0.13"
|
||||||
|
bollard = "0.17"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
anyhow = "1"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# `bytes::Bytes` is used in MockBackend::logs() to construct LogOutput variants
|
||||||
|
bytes = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = "0.12"
|
||||||
36
agent/Dockerfile
Normal file
36
agent/Dockerfile
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Context: project root (needed so build.rs can access proto/)
|
||||||
|
FROM rust:slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
protobuf-compiler \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy proto first (referenced by build.rs as ../proto/...)
|
||||||
|
COPY proto/ ./proto/
|
||||||
|
|
||||||
|
# Cache dependencies: copy manifests, build with dummy main, then discard.
|
||||||
|
COPY agent/Cargo.toml agent/Cargo.lock ./agent/
|
||||||
|
COPY agent/build.rs ./agent/
|
||||||
|
WORKDIR /src/agent
|
||||||
|
RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||||
|
cargo build --release && \
|
||||||
|
rm -rf src
|
||||||
|
|
||||||
|
# Full build.
|
||||||
|
COPY agent/src ./src
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /src/agent/target/release/containarr-agent /usr/local/bin/containarr-agent
|
||||||
|
|
||||||
|
ENTRYPOINT ["containarr-agent"]
|
||||||
6
agent/build.rs
Normal file
6
agent/build.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tonic_build::configure()
|
||||||
|
.build_server(false)
|
||||||
|
.compile_protos(&["../proto/agent/v1/agent.proto"], &["../proto"])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
385
agent/src/docker.rs
Normal file
385
agent/src/docker.rs
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use bollard::{
|
||||||
|
container::{
|
||||||
|
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
|
||||||
|
StartContainerOptions, StopContainerOptions,
|
||||||
|
},
|
||||||
|
Docker,
|
||||||
|
};
|
||||||
|
use futures_util::Stream;
|
||||||
|
use std::{collections::HashMap, pin::Pin};
|
||||||
|
|
||||||
|
use crate::proto::{ContainerInfo, ContainerPort};
|
||||||
|
|
||||||
|
// ── Public trait ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Abstraction over Docker operations, allowing tests to provide a mock backend.
|
||||||
|
pub trait ContainerBackend: Clone + Send + Sync + 'static {
|
||||||
|
fn list_containers(
|
||||||
|
&self,
|
||||||
|
) -> impl std::future::Future<Output = Result<Vec<ContainerInfo>>> + Send;
|
||||||
|
|
||||||
|
fn start(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
fn stop(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
fn restart(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
fn remove(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
||||||
|
fn logs(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
follow: bool,
|
||||||
|
tail: i32,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Real implementation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DockerClient {
|
||||||
|
inner: Docker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DockerClient {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
inner: Docker::connect_with_socket_defaults()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContainerBackend for DockerClient {
|
||||||
|
async fn list_containers(&self) -> Result<Vec<ContainerInfo>> {
|
||||||
|
let opts = ListContainersOptions::<String> {
|
||||||
|
all: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let containers = self.inner.list_containers(Some(opts)).await?;
|
||||||
|
|
||||||
|
let result = containers
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| {
|
||||||
|
let id = c.id.unwrap_or_default();
|
||||||
|
let name = c
|
||||||
|
.names
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let ports = c
|
||||||
|
.ports
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| ContainerPort {
|
||||||
|
host_port: p.public_port.unwrap_or(0) as u32,
|
||||||
|
container_port: p.private_port as u32,
|
||||||
|
protocol: p
|
||||||
|
.typ
|
||||||
|
.map(|t| format!("{:?}", t).to_lowercase())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
host_ip: p.ip.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let labels: HashMap<String, String> = c.labels.unwrap_or_default();
|
||||||
|
let compose_project = labels
|
||||||
|
.get("com.docker.compose.project")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
ContainerInfo {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
image: c.image.unwrap_or_default(),
|
||||||
|
status: c.status.unwrap_or_default(),
|
||||||
|
state: c.state.unwrap_or_default(),
|
||||||
|
ports,
|
||||||
|
created_at: c.created.unwrap_or(0),
|
||||||
|
labels,
|
||||||
|
compose_project,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self, id: &str) -> Result<()> {
|
||||||
|
self.inner
|
||||||
|
.start_container(id, None::<StartContainerOptions<String>>)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self, id: &str) -> Result<()> {
|
||||||
|
self.inner
|
||||||
|
.stop_container(id, Some(StopContainerOptions { t: 10 }))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restart(&self, id: &str) -> Result<()> {
|
||||||
|
self.inner.restart_container(id, None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
self.inner
|
||||||
|
.remove_container(
|
||||||
|
id,
|
||||||
|
Some(RemoveContainerOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logs(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
follow: bool,
|
||||||
|
tail: i32,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>> {
|
||||||
|
let tail_str = if tail > 0 {
|
||||||
|
tail.to_string()
|
||||||
|
} else {
|
||||||
|
"100".to_string()
|
||||||
|
};
|
||||||
|
Box::pin(self.inner.logs(
|
||||||
|
id,
|
||||||
|
Some(LogsOptions::<String> {
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
follow,
|
||||||
|
tail: tail_str,
|
||||||
|
timestamps: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio_stream::once as stream_once;
|
||||||
|
|
||||||
|
// ── Minimal mock backend ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Records which method was last called and with what container id, so
|
||||||
|
/// tests can assert on behaviour without a real Docker daemon.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct MockBackend {
|
||||||
|
pub calls: Arc<Mutex<Vec<String>>>,
|
||||||
|
/// When Some(msg) every async method returns Err(anyhow!(msg)).
|
||||||
|
pub fail_with: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockBackend {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failing(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: Default::default(),
|
||||||
|
fail_with: Some(msg.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, entry: String) {
|
||||||
|
self.calls.lock().unwrap().push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_err(&self) -> Result<()> {
|
||||||
|
if let Some(ref m) = self.fail_with {
|
||||||
|
anyhow::bail!("{}", m);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContainerBackend for MockBackend {
|
||||||
|
async fn list_containers(&self) -> Result<Vec<ContainerInfo>> {
|
||||||
|
self.record("list".to_string());
|
||||||
|
self.maybe_err()?;
|
||||||
|
Ok(vec![ContainerInfo {
|
||||||
|
id: "abc123".to_string(),
|
||||||
|
name: "test-container".to_string(),
|
||||||
|
image: "nginx:latest".to_string(),
|
||||||
|
status: "Up 2 hours".to_string(),
|
||||||
|
state: "running".to_string(),
|
||||||
|
ports: vec![ContainerPort {
|
||||||
|
host_port: 8080,
|
||||||
|
container_port: 80,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
host_ip: "0.0.0.0".to_string(),
|
||||||
|
}],
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
labels: HashMap::new(),
|
||||||
|
compose_project: String::new(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self, id: &str) -> Result<()> {
|
||||||
|
self.record(format!("start:{id}"));
|
||||||
|
self.maybe_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self, id: &str) -> Result<()> {
|
||||||
|
self.record(format!("stop:{id}"));
|
||||||
|
self.maybe_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restart(&self, id: &str) -> Result<()> {
|
||||||
|
self.record(format!("restart:{id}"));
|
||||||
|
self.maybe_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
self.record(format!("remove:{id}"));
|
||||||
|
self.maybe_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logs(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
_follow: bool,
|
||||||
|
_tail: i32,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>
|
||||||
|
{
|
||||||
|
self.record(format!("logs:{id}"));
|
||||||
|
let chunk = LogOutput::StdOut {
|
||||||
|
message: Bytes::from("hello from mock\n"),
|
||||||
|
};
|
||||||
|
Box::pin(stream_once(Ok(chunk)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list_containers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_containers_returns_one_entry() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let containers = backend.list_containers().await.unwrap();
|
||||||
|
assert_eq!(containers.len(), 1);
|
||||||
|
assert_eq!(containers[0].id, "abc123");
|
||||||
|
assert_eq!(containers[0].name, "test-container");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_containers_records_call() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.list_containers().await.unwrap();
|
||||||
|
let calls = backend.calls.lock().unwrap().clone();
|
||||||
|
assert_eq!(calls, vec!["list"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_containers_propagates_error() {
|
||||||
|
let backend = MockBackend::failing("docker down");
|
||||||
|
let err = backend.list_containers().await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("docker down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── start / stop / restart / remove ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_start_records_id() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.start("cid-1").await.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["start:cid-1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_stop_records_id() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.stop("cid-2").await.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["stop:cid-2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_restart_records_id() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.restart("cid-3").await.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["restart:cid-3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_remove_records_id() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.remove("cid-4").await.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove:cid-4"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_operations_propagate_errors() {
|
||||||
|
let backend = MockBackend::failing("socket gone");
|
||||||
|
assert!(backend.start("x").await.is_err());
|
||||||
|
assert!(backend.stop("x").await.is_err());
|
||||||
|
assert!(backend.restart("x").await.is_err());
|
||||||
|
assert!(backend.remove("x").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── logs stream ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_logs_yields_stdout_chunk() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let mut stream = backend.logs("cid-5", false, 10);
|
||||||
|
let item = stream.next().await.unwrap().unwrap();
|
||||||
|
match item {
|
||||||
|
LogOutput::StdOut { message } => {
|
||||||
|
assert_eq!(message.as_ref(), b"hello from mock\n");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected variant: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_logs_records_id() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let mut stream = backend.logs("cid-5", false, 10);
|
||||||
|
// drain the stream
|
||||||
|
while stream.next().await.is_some() {}
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["logs:cid-5"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContainerInfo / ContainerPort field mapping ───────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_info_fields_are_accessible() {
|
||||||
|
let port = ContainerPort {
|
||||||
|
host_port: 443,
|
||||||
|
container_port: 8443,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
host_ip: "127.0.0.1".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(port.host_port, 443);
|
||||||
|
assert_eq!(port.container_port, 8443);
|
||||||
|
assert_eq!(port.protocol, "tcp");
|
||||||
|
|
||||||
|
let info = ContainerInfo {
|
||||||
|
id: "id1".to_string(),
|
||||||
|
name: "name1".to_string(),
|
||||||
|
image: "img".to_string(),
|
||||||
|
status: "running".to_string(),
|
||||||
|
state: "running".to_string(),
|
||||||
|
ports: vec![port],
|
||||||
|
created_at: 42,
|
||||||
|
labels: HashMap::new(),
|
||||||
|
compose_project: "proj".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(info.ports.len(), 1);
|
||||||
|
assert_eq!(info.compose_project, "proj");
|
||||||
|
}
|
||||||
|
}
|
||||||
434
agent/src/main.rs
Normal file
434
agent/src/main.rs
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
mod docker;
|
||||||
|
|
||||||
|
pub mod proto {
|
||||||
|
tonic::include_proto!("containarr.agent.v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use bollard::container::LogOutput;
|
||||||
|
use docker::{ContainerBackend, DockerClient};
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
|
use proto::{
|
||||||
|
agent_gateway_client::AgentGatewayClient,
|
||||||
|
agent_message, server_message,
|
||||||
|
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
||||||
|
};
|
||||||
|
use std::{collections::HashMap, env, time::Duration};
|
||||||
|
use tokio::{sync::mpsc, task::JoinHandle, time};
|
||||||
|
use tonic::Request;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
const SNAPSHOT_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.json()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let server_url = env::var("CONTAINARR_SERVER_URL")
|
||||||
|
.context("CONTAINARR_SERVER_URL not set")?;
|
||||||
|
let token = env::var("CONTAINARR_AGENT_TOKEN")
|
||||||
|
.context("CONTAINARR_AGENT_TOKEN not set")?;
|
||||||
|
let hostname = env::var("HOSTNAME").unwrap_or_else(|_| "unknown".into());
|
||||||
|
|
||||||
|
let docker = DockerClient::new().context("connect to Docker socket")?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Err(e) = run(&server_url, &token, &hostname, docker.clone()).await {
|
||||||
|
error!("connection lost: {:#}", e);
|
||||||
|
}
|
||||||
|
info!("reconnecting in {:?}", RECONNECT_DELAY);
|
||||||
|
time::sleep(RECONNECT_DELAY).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Result<()> {
|
||||||
|
info!("connecting to server at {}", url);
|
||||||
|
let mut client = AgentGatewayClient::connect(url.to_string()).await?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<AgentMessage>(64);
|
||||||
|
|
||||||
|
tx.send(AgentMessage {
|
||||||
|
payload: Some(agent_message::Payload::Handshake(AgentHandshake {
|
||||||
|
token: token.to_string(),
|
||||||
|
hostname: hostname.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let outbound = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
|
let mut inbound = client.tunnel(Request::new(outbound)).await?.into_inner();
|
||||||
|
|
||||||
|
let mut snapshot_ticker = time::interval(SNAPSHOT_INTERVAL);
|
||||||
|
let mut log_tasks: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = snapshot_ticker.tick() => {
|
||||||
|
match time::timeout(Duration::from_secs(5), docker.list_containers()).await {
|
||||||
|
Err(_) => warn!("docker list timed out"),
|
||||||
|
Ok(Err(e)) => warn!("docker list failed: {:#}", e),
|
||||||
|
Ok(Ok(containers)) => {
|
||||||
|
let msg = AgentMessage {
|
||||||
|
payload: Some(agent_message::Payload::Snapshot(ContainerSnapshot {
|
||||||
|
containers,
|
||||||
|
timestamp: unix_now(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if tx.send(msg).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = inbound.message() => {
|
||||||
|
match result? {
|
||||||
|
None => break,
|
||||||
|
Some(msg) => match msg.payload {
|
||||||
|
Some(server_message::Payload::ContainerCmd(cmd)) => {
|
||||||
|
let ok = execute_action(&docker, &cmd.container_id, cmd.action).await;
|
||||||
|
let _ = tx.send(AgentMessage {
|
||||||
|
payload: Some(agent_message::Payload::Result(proto::CommandResult {
|
||||||
|
command_id: cmd.command_id,
|
||||||
|
success: ok.is_ok(),
|
||||||
|
error: ok.err().map(|e| e.to_string()).unwrap_or_default(),
|
||||||
|
})),
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
Some(server_message::Payload::StreamLogs(cmd)) => {
|
||||||
|
if let Some(old) = log_tasks.remove(&cmd.container_id) {
|
||||||
|
old.abort();
|
||||||
|
}
|
||||||
|
let docker_clone = docker.clone();
|
||||||
|
let tx_clone = tx.clone();
|
||||||
|
let cid = cmd.container_id.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut stream = docker_clone.logs(&cid, cmd.follow, cmd.tail);
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(output) => {
|
||||||
|
let (stream_name, data) = match output {
|
||||||
|
LogOutput::StdOut { message } => ("stdout", message),
|
||||||
|
LogOutput::StdErr { message } => ("stderr", message),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let msg = AgentMessage {
|
||||||
|
payload: Some(agent_message::Payload::LogChunk(
|
||||||
|
proto::LogChunk {
|
||||||
|
container_id: cid.clone(),
|
||||||
|
stream: stream_name.to_string(),
|
||||||
|
data: data.to_vec(),
|
||||||
|
timestamp: unix_now(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
if tx_clone.send(msg).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("log stream error for {}: {:#}", cid, e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log_tasks.insert(cmd.container_id, handle);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, task) in log_tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn execute_action<B: ContainerBackend>(
|
||||||
|
docker: &B,
|
||||||
|
id: &str,
|
||||||
|
action: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
match ContainerAction::try_from(action)? {
|
||||||
|
ContainerAction::Start => docker.start(id).await,
|
||||||
|
ContainerAction::Stop => docker.stop(id).await,
|
||||||
|
ContainerAction::Restart => docker.restart(id).await,
|
||||||
|
ContainerAction::Remove => docker.remove(id).await,
|
||||||
|
ContainerAction::Unspecified => anyhow::bail!("unspecified action"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::docker::tests::MockBackend;
|
||||||
|
use proto::ContainerAction;
|
||||||
|
|
||||||
|
// ── unix_now ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_now_is_positive_and_recent() {
|
||||||
|
let t = unix_now();
|
||||||
|
// Should be somewhere past 2020-01-01 (1_577_836_800)
|
||||||
|
assert!(t > 1_577_836_800, "unix_now returned {t}, expected > 2020");
|
||||||
|
// And not absurdly far in the future (year 2100 = 4_102_444_800)
|
||||||
|
assert!(t < 4_102_444_800, "unix_now returned {t}, looks wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_now_is_monotone_or_equal() {
|
||||||
|
let t1 = unix_now();
|
||||||
|
let t2 = unix_now();
|
||||||
|
// Same second or the second just ticked forward — never backwards
|
||||||
|
assert!(t2 >= t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContainerAction enum parsing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_action_from_valid_int() {
|
||||||
|
assert_eq!(ContainerAction::try_from(0).unwrap(), ContainerAction::Unspecified);
|
||||||
|
assert_eq!(ContainerAction::try_from(1).unwrap(), ContainerAction::Start);
|
||||||
|
assert_eq!(ContainerAction::try_from(2).unwrap(), ContainerAction::Stop);
|
||||||
|
assert_eq!(ContainerAction::try_from(3).unwrap(), ContainerAction::Restart);
|
||||||
|
assert_eq!(ContainerAction::try_from(4).unwrap(), ContainerAction::Remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn container_action_from_invalid_int_errors() {
|
||||||
|
assert!(ContainerAction::try_from(99).is_err());
|
||||||
|
assert!(ContainerAction::try_from(-1).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── execute_action — routing ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_start_calls_start() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
execute_action(&backend, "container-a", ContainerAction::Start as i32)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["start:container-a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_stop_calls_stop() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
execute_action(&backend, "container-b", ContainerAction::Stop as i32)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["stop:container-b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_restart_calls_restart() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
execute_action(&backend, "container-c", ContainerAction::Restart as i32)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["restart:container-c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_remove_calls_remove() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
execute_action(&backend, "container-d", ContainerAction::Remove as i32)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(*backend.calls.lock().unwrap(), vec!["remove:container-d"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_unspecified_returns_error() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let result =
|
||||||
|
execute_action(&backend, "container-e", ContainerAction::Unspecified as i32).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("unspecified action"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_invalid_int_returns_error() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let result = execute_action(&backend, "container-f", 999).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── execute_action — error propagation ───────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_start_propagates_backend_error() {
|
||||||
|
let backend = MockBackend::failing("docker not reachable");
|
||||||
|
let result =
|
||||||
|
execute_action(&backend, "x", ContainerAction::Start as i32).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("docker not reachable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_stop_propagates_backend_error() {
|
||||||
|
let backend = MockBackend::failing("timeout");
|
||||||
|
let result =
|
||||||
|
execute_action(&backend, "x", ContainerAction::Stop as i32).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_restart_propagates_backend_error() {
|
||||||
|
let backend = MockBackend::failing("timeout");
|
||||||
|
let result =
|
||||||
|
execute_action(&backend, "x", ContainerAction::Restart as i32).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_action_remove_propagates_backend_error() {
|
||||||
|
let backend = MockBackend::failing("permission denied");
|
||||||
|
let result =
|
||||||
|
execute_action(&backend, "x", ContainerAction::Remove as i32).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log-task abort logic (unit-level) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Spawn a long-running task and verify it is aborted when its JoinHandle
|
||||||
|
/// is dropped via abort() — exercises the same flow as the StreamLogs branch.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawned_task_can_be_aborted() {
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
let (started_tx, started_rx) = oneshot::channel::<()>();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _ = started_tx.send(());
|
||||||
|
// Block indefinitely simulating a live log stream
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
});
|
||||||
|
// Wait until the task is definitely running
|
||||||
|
started_rx.await.unwrap();
|
||||||
|
handle.abort();
|
||||||
|
let result = handle.await;
|
||||||
|
assert!(result.is_err()); // JoinError with is_cancelled() == true
|
||||||
|
assert!(result.unwrap_err().is_cancelled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawning a second task for the same container_id aborts the first one,
|
||||||
|
/// mirroring the log_tasks.remove() + old.abort() pattern in main.rs.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn second_stream_aborts_first() {
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
let mut log_tasks: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||||
|
let cid = "my-container".to_string();
|
||||||
|
|
||||||
|
let (tx1, rx1) = oneshot::channel::<()>();
|
||||||
|
let h1 = tokio::spawn(async move {
|
||||||
|
let _ = tx1.send(());
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
});
|
||||||
|
rx1.await.unwrap();
|
||||||
|
log_tasks.insert(cid.clone(), h1);
|
||||||
|
|
||||||
|
// Second StreamLogs for the same cid — abort the old task
|
||||||
|
if let Some(old) = log_tasks.remove(&cid) {
|
||||||
|
old.abort();
|
||||||
|
}
|
||||||
|
let (tx2, rx2) = oneshot::channel::<()>();
|
||||||
|
let h2 = tokio::spawn(async move {
|
||||||
|
let _ = tx2.send(());
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
});
|
||||||
|
rx2.await.unwrap();
|
||||||
|
log_tasks.insert(cid.clone(), h2);
|
||||||
|
|
||||||
|
assert_eq!(log_tasks.len(), 1);
|
||||||
|
// Clean up
|
||||||
|
for (_, h) in log_tasks {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proto message construction ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_handshake_fields_roundtrip() {
|
||||||
|
let hs = AgentHandshake {
|
||||||
|
token: "tok".to_string(),
|
||||||
|
hostname: "host".to_string(),
|
||||||
|
arch: "x86_64".to_string(),
|
||||||
|
os: "linux".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(hs.token, "tok");
|
||||||
|
assert_eq!(hs.hostname, "host");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_message_wraps_handshake() {
|
||||||
|
let msg = AgentMessage {
|
||||||
|
payload: Some(agent_message::Payload::Handshake(AgentHandshake {
|
||||||
|
token: "t".to_string(),
|
||||||
|
hostname: "h".to_string(),
|
||||||
|
arch: "arm64".to_string(),
|
||||||
|
os: "linux".to_string(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
msg.payload,
|
||||||
|
Some(agent_message::Payload::Handshake(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_result_ok_fields() {
|
||||||
|
let r = proto::CommandResult {
|
||||||
|
command_id: "cmd-1".to_string(),
|
||||||
|
success: true,
|
||||||
|
error: String::new(),
|
||||||
|
};
|
||||||
|
assert!(r.success);
|
||||||
|
assert!(r.error.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_result_err_fields() {
|
||||||
|
let r = proto::CommandResult {
|
||||||
|
command_id: "cmd-2".to_string(),
|
||||||
|
success: false,
|
||||||
|
error: "container not found".to_string(),
|
||||||
|
};
|
||||||
|
assert!(!r.success);
|
||||||
|
assert_eq!(r.error, "container not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_chunk_fields() {
|
||||||
|
let chunk = proto::LogChunk {
|
||||||
|
container_id: "cid".to_string(),
|
||||||
|
stream: "stdout".to_string(),
|
||||||
|
data: b"hello".to_vec(),
|
||||||
|
timestamp: 12345,
|
||||||
|
};
|
||||||
|
assert_eq!(chunk.stream, "stdout");
|
||||||
|
assert_eq!(chunk.data, b"hello");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
docker-compose.agent.yml
Normal file
13
docker-compose.agent.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
agent:
|
||||||
|
image: ghcr.io/containarr/agent:latest # or build locally
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: agent/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
CONTAINARR_SERVER_URL: "${CONTAINARR_SERVER_URL}"
|
||||||
|
CONTAINARR_AGENT_TOKEN: "${CONTAINARR_AGENT_TOKEN}"
|
||||||
|
RUST_LOG: "info"
|
||||||
38
docker-compose.server.yml
Normal file
38
docker-compose.server.yml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
image: containarr-server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # HTTP + WebSocket (PWA)
|
||||||
|
- "9090:9090" # gRPC (agents)
|
||||||
|
volumes:
|
||||||
|
- containarr-data:/data
|
||||||
|
environment:
|
||||||
|
DB_PATH: /data/containarr.db
|
||||||
|
HTTP_ADDR: ":8080"
|
||||||
|
GRPC_ADDR: ":9090"
|
||||||
|
JWT_SECRET: "${JWT_SECRET}"
|
||||||
|
ADMIN_USER: "${ADMIN_USER}"
|
||||||
|
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
|
||||||
|
BOOTSTRAP_TOKENS: "local:${LOCAL_AGENT_TOKEN}"
|
||||||
|
|
||||||
|
# Agent for the local VM (same host as the server).
|
||||||
|
agent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: agent/Dockerfile
|
||||||
|
image: containarr-agent:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
CONTAINARR_SERVER_URL: "http://server:9090"
|
||||||
|
CONTAINARR_AGENT_TOKEN: "${LOCAL_AGENT_TOKEN}"
|
||||||
|
RUST_LOG: "info"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
containarr-data:
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Containarr",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
100
proto/agent/v1/agent.proto
Normal file
100
proto/agent/v1/agent.proto
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package containarr.agent.v1;
|
||||||
|
|
||||||
|
option go_package = "github.com/containarr/server/internal/proto/agentv1";
|
||||||
|
|
||||||
|
// ── Shared types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message ContainerPort {
|
||||||
|
uint32 host_port = 1;
|
||||||
|
uint32 container_port = 2;
|
||||||
|
string protocol = 3;
|
||||||
|
string host_ip = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContainerInfo {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string image = 3;
|
||||||
|
string status = 4;
|
||||||
|
string state = 5;
|
||||||
|
repeated ContainerPort ports = 6;
|
||||||
|
int64 created_at = 7;
|
||||||
|
map<string, string> labels = 8;
|
||||||
|
string compose_project = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent → Server ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message AgentHandshake {
|
||||||
|
string token = 1;
|
||||||
|
string hostname = 2;
|
||||||
|
string arch = 3;
|
||||||
|
string os = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContainerSnapshot {
|
||||||
|
repeated ContainerInfo containers = 1;
|
||||||
|
int64 timestamp = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CommandResult {
|
||||||
|
string command_id = 1;
|
||||||
|
bool success = 2;
|
||||||
|
string error = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogChunk {
|
||||||
|
string container_id = 1;
|
||||||
|
string stream = 2; // "stdout" | "stderr"
|
||||||
|
bytes data = 3;
|
||||||
|
int64 timestamp = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AgentMessage {
|
||||||
|
oneof payload {
|
||||||
|
AgentHandshake handshake = 1;
|
||||||
|
ContainerSnapshot snapshot = 2;
|
||||||
|
CommandResult result = 3;
|
||||||
|
LogChunk log_chunk = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server → Agent ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum ContainerAction {
|
||||||
|
CONTAINER_ACTION_UNSPECIFIED = 0;
|
||||||
|
CONTAINER_ACTION_START = 1;
|
||||||
|
CONTAINER_ACTION_STOP = 2;
|
||||||
|
CONTAINER_ACTION_RESTART = 3;
|
||||||
|
CONTAINER_ACTION_REMOVE = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContainerCommand {
|
||||||
|
string command_id = 1;
|
||||||
|
string container_id = 2;
|
||||||
|
ContainerAction action = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StreamLogsCommand {
|
||||||
|
string command_id = 1;
|
||||||
|
string container_id = 2;
|
||||||
|
bool follow = 3;
|
||||||
|
int32 tail = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServerMessage {
|
||||||
|
oneof payload {
|
||||||
|
ContainerCommand container_cmd = 1;
|
||||||
|
StreamLogsCommand stream_logs = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
service AgentGateway {
|
||||||
|
// Bidirectional stream: agent connects once and maintains the tunnel.
|
||||||
|
// First AgentMessage must be AgentHandshake.
|
||||||
|
rpc Tunnel(stream AgentMessage) returns (stream ServerMessage);
|
||||||
|
}
|
||||||
20
server/Dockerfile
Normal file
20
server/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
COPY . .
|
||||||
|
RUN go mod tidy && CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o /bin/containarr-server ./cmd/server
|
||||||
|
|
||||||
|
# ── Runtime ───────────────────────────────────────────────────────────────────
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /bin/containarr-server /usr/local/bin/containarr-server
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8080 9090
|
||||||
|
|
||||||
|
ENTRYPOINT ["containarr-server"]
|
||||||
139
server/cmd/server/main.go
Normal file
139
server/cmd/server/main.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/containarr/server/internal/api"
|
||||||
|
"github.com/containarr/server/internal/broker"
|
||||||
|
grpcgateway "github.com/containarr/server/internal/grpc"
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
"github.com/containarr/server/internal/store"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||||
|
|
||||||
|
dbPath := getenv("DB_PATH", "/data/containarr.db")
|
||||||
|
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||||
|
grpcAddr := getenv("GRPC_ADDR", ":9090")
|
||||||
|
|
||||||
|
db, err := store.New(dbPath)
|
||||||
|
must(err, "open store")
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
bootstrapAdmin(db)
|
||||||
|
bootstrapTokens(db)
|
||||||
|
|
||||||
|
reg := grpcgateway.NewRegistry()
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
// gRPC server.
|
||||||
|
gw := grpcgateway.NewGateway(db, reg, brk)
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
agentv1.RegisterAgentGatewayServer(grpcServer, gw)
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", grpcAddr)
|
||||||
|
must(err, "listen grpc")
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("gRPC listening", "addr", grpcAddr)
|
||||||
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
|
slog.Error("gRPC serve", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// HTTP server.
|
||||||
|
h := api.NewHandler(db, reg, brk)
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: httpAddr,
|
||||||
|
Handler: api.NewRouter(h),
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 0, // disabled for WebSocket handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("HTTP listening", "addr", httpAddr)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("HTTP serve", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown.
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
slog.Info("shutting down")
|
||||||
|
grpcServer.GracefulStop()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = httpServer.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapAdmin creates the admin user from env vars if it doesn't exist yet.
|
||||||
|
func bootstrapAdmin(db *store.Store) {
|
||||||
|
username := getenv("ADMIN_USER", "admin")
|
||||||
|
password := getenv("ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
exists, err := db.UserExists(username)
|
||||||
|
if err != nil || exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("bcrypt admin", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.UpsertUser(username, string(hash)); err != nil {
|
||||||
|
slog.Error("seed admin user", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("admin user created", "username", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapTokens seeds agent tokens from BOOTSTRAP_TOKENS env var.
|
||||||
|
// Format: "hostname:token,hostname2:token2"
|
||||||
|
func bootstrapTokens(db *store.Store) {
|
||||||
|
raw := os.Getenv("BOOTSTRAP_TOKENS")
|
||||||
|
if raw == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pair := range strings.Split(raw, ",") {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(pair), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostname, token := parts[0], parts[1]
|
||||||
|
if err := db.CreateAgentToken(uuid.NewString(), token, hostname); err != nil {
|
||||||
|
slog.Warn("bootstrap token already exists", "hostname", hostname)
|
||||||
|
} else {
|
||||||
|
slog.Info("bootstrapped agent token", "hostname", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error, msg string) {
|
||||||
|
if err != nil {
|
||||||
|
slog.Error(msg, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server/go.mod
Normal file
21
server/go.mod
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
module github.com/containarr/server
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
golang.org/x/crypto v0.21.0
|
||||||
|
google.golang.org/grpc v1.64.0
|
||||||
|
google.golang.org/protobuf v1.34.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
|
||||||
|
)
|
||||||
26
server/go.sum
Normal file
26
server/go.sum
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||||
|
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||||
|
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
550
server/internal/api/api_test.go
Normal file
550
server/internal/api/api_test.go
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containarr/server/internal/broker"
|
||||||
|
grpcgateway "github.com/containarr/server/internal/grpc"
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
"github.com/containarr/server/internal/store"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T) (*Handler, *store.Store, *grpcgateway.Registry, *broker.Broker) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := store.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.New: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
|
||||||
|
reg := grpcgateway.NewRegistry()
|
||||||
|
b := broker.New()
|
||||||
|
h := NewHandler(s, reg, b)
|
||||||
|
return h, s, reg, b
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeJWT(t *testing.T, subject string) string {
|
||||||
|
t.Helper()
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: subject,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
signed, err := token.SignedString(jwtSecret())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("makeJWT: %v", err)
|
||||||
|
}
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerHeader(token string) string {
|
||||||
|
return "Bearer " + token
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, handler http.HandlerFunc, path string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSONAuth(t *testing.T, handler http.HandlerFunc, path string, body any, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", bearerHeader(token))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Wrap handler with requireJWT so claims land in context.
|
||||||
|
requireJWT(handler).ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extractToken ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestExtractToken_BearerHeader(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer mytoken")
|
||||||
|
if got := extractToken(req); got != "mytoken" {
|
||||||
|
t.Errorf("expected 'mytoken', got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractToken_QueryParam(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/?token=querytoken", nil)
|
||||||
|
if got := extractToken(req); got != "querytoken" {
|
||||||
|
t.Errorf("expected 'querytoken', got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractToken_Empty(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if got := extractToken(req); got != "" {
|
||||||
|
t.Errorf("expected empty, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractToken_ShortAuthHeader(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bear") // len < 7
|
||||||
|
if got := extractToken(req); got != "" {
|
||||||
|
t.Errorf("expected empty for short header, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── requireJWT middleware ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestRequireJWT_MissingToken(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
requireJWT(next).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("handler should not be called without token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireJWT_InvalidToken(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer not.a.real.token")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
requireJWT(next).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireJWT_ValidToken(t *testing.T) {
|
||||||
|
token := makeJWT(t, "alice")
|
||||||
|
called := false
|
||||||
|
var gotSubject string
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
c, ok := claimsFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
t.Error("claims not in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotSubject = c.Subject
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", bearerHeader(token))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
requireJWT(next).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if !called {
|
||||||
|
t.Error("handler was not called")
|
||||||
|
}
|
||||||
|
if gotSubject != "alice" {
|
||||||
|
t.Errorf("expected subject 'alice', got %q", gotSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireJWT_WrongSecret(t *testing.T) {
|
||||||
|
// Sign with a different secret
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: "hacker",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
signed, _ := token.SignedString([]byte("wrong-secret"))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", bearerHeader(signed))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
requireJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestLogin_Success(t *testing.T) {
|
||||||
|
h, s, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
||||||
|
_ = s.UpsertUser("alice", string(hash))
|
||||||
|
|
||||||
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
||||||
|
"username": "alice",
|
||||||
|
"password": "password",
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["token"] == "" {
|
||||||
|
t.Error("expected non-empty token in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_WrongPassword(t *testing.T) {
|
||||||
|
h, s, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost)
|
||||||
|
_ = s.UpsertUser("alice", string(hash))
|
||||||
|
|
||||||
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
||||||
|
"username": "alice",
|
||||||
|
"password": "wrong",
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_UnknownUser(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
||||||
|
"username": "nobody",
|
||||||
|
"password": "pass",
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_BadBody(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader("not-json"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.Login(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_EmptyFields(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
w := postJSON(t, h.Login, "/api/v1/auth/login", map[string]string{
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ChangePassword ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestChangePassword_Success(t *testing.T) {
|
||||||
|
h, s, _, _ := newTestHandler(t)
|
||||||
|
os.Setenv("JWT_SECRET", "test-secret-change-pw")
|
||||||
|
defer os.Unsetenv("JWT_SECRET")
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("oldpass"), bcrypt.MinCost)
|
||||||
|
_ = s.UpsertUser("alice", string(hash))
|
||||||
|
|
||||||
|
token := makeJWT(t, "alice")
|
||||||
|
w := postJSONAuth(t, h.ChangePassword, "/api/v1/auth/change-password", map[string]string{
|
||||||
|
"current_password": "oldpass",
|
||||||
|
"new_password": "newpass",
|
||||||
|
}, token)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d — body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify new hash is stored
|
||||||
|
newHash, _ := s.GetUserHash("alice")
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(newHash), []byte("newpass")) != nil {
|
||||||
|
t.Error("new password hash does not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword_WrongCurrentPassword(t *testing.T) {
|
||||||
|
h, s, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost)
|
||||||
|
_ = s.UpsertUser("alice", string(hash))
|
||||||
|
|
||||||
|
token := makeJWT(t, "alice")
|
||||||
|
w := postJSONAuth(t, h.ChangePassword, "/api/v1/auth/change-password", map[string]string{
|
||||||
|
"current_password": "wrong",
|
||||||
|
"new_password": "newpass",
|
||||||
|
}, token)
|
||||||
|
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ListAgents ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestListAgents_Empty(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListAgents(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var agents []agentDTO
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&agents); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(agents) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d", len(agents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAgents_PersistenceAndLive(t *testing.T) {
|
||||||
|
h, s, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
||||||
|
// Register a2 in the registry (simulating live agent)
|
||||||
|
reg.Register("a2", "host2", "alias2", "192.168.1.1", "arm64", "linux")
|
||||||
|
_ = s.CreateAgentToken("a2", "t2", "host2")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListAgents(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var agents []agentDTO
|
||||||
|
json.NewDecoder(w.Body).Decode(&agents)
|
||||||
|
|
||||||
|
if len(agents) != 2 {
|
||||||
|
t.Fatalf("expected 2 agents, got %d", len(agents))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a2 — it should be online
|
||||||
|
var a2 *agentDTO
|
||||||
|
for i := range agents {
|
||||||
|
if agents[i].ID == "a2" {
|
||||||
|
a2 = &agents[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a2 == nil {
|
||||||
|
t.Fatal("a2 not found in list")
|
||||||
|
}
|
||||||
|
if !a2.Online {
|
||||||
|
t.Error("a2 should be online (registered in registry)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CreateAgentToken ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCreateAgentToken_Success(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
b, _ := json.Marshal(map[string]string{"hostname": "new-agent"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/token", bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CreateAgentToken(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
if resp["agent_id"] == "" || resp["token"] == "" {
|
||||||
|
t.Errorf("missing agent_id or token in response: %v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAgentToken_MissingHostname(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
b, _ := json.Marshal(map[string]string{"hostname": ""})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/token", bytes.NewReader(b))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.CreateAgentToken(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UpdateAgent ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestUpdateAgent_Success(t *testing.T) {
|
||||||
|
h, s, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
||||||
|
reg.Register("a1", "host1", "old", "ip", "arch", "os")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"alias": "new-alias"})
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Patch("/api/v1/agents/{agentID}", h.UpdateAgent)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/agents/a1", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp agentDTO
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp.Alias != "new-alias" {
|
||||||
|
t.Errorf("expected alias 'new-alias', got %q", resp.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm registry also updated
|
||||||
|
state, ok := reg.Get("a1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("agent not in registry")
|
||||||
|
}
|
||||||
|
if state.Alias != "new-alias" {
|
||||||
|
t.Errorf("registry alias not updated, got %q", state.Alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ListContainers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestListContainers_Empty(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListContainers(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListContainers_WithData(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
||||||
|
reg.UpdateContainers("a1", []*agentv1.ContainerInfo{
|
||||||
|
{Id: "c1", Name: "web"},
|
||||||
|
{Id: "c2", Name: "db"},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/containers", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListContainers(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Container *agentv1.ContainerInfo `json:"container"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Errorf("expected 2 containers, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContainerAction ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestContainerAction_AgentNotConnected(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"action": "start"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/containers/c1/action", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerAction_InvalidAction(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"action": "explode"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/containers/c1/action", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerAction_Success(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"action": "stop"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/containers/c1/action", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/api/v1/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]string
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp["command_id"] == "" {
|
||||||
|
t.Error("expected command_id in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
125
server/internal/api/auth.go
Normal file
125
server/internal/api/auth.go
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jwtSecret() []byte {
|
||||||
|
if s := os.Getenv("JWT_SECRET"); s != "" {
|
||||||
|
return []byte(s)
|
||||||
|
}
|
||||||
|
return []byte("dev-secret-change-me")
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwtClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Username == "" || body.Password == "" {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := h.store.GetUserHash(body.Username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) != nil {
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwtClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: body.Username,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
signed, err := token.SignedString(jwtSecret())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"token": signed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := claimsFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.NewPassword == "" {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := h.store.GetUserHash(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.CurrentPassword)) != nil {
|
||||||
|
http.Error(w, "mot de passe actuel incorrect", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpsertUser(claims.Subject, string(newHash)); err != nil {
|
||||||
|
http.Error(w, "store error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireJWT(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := extractToken(r)
|
||||||
|
if raw == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := jwt.ParseWithClaims(raw, &jwtClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return jwtSecret(), nil
|
||||||
|
})
|
||||||
|
if err != nil || !t.Valid {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(
|
||||||
|
contextWithClaims(r.Context(), t.Claims.(*jwtClaims)),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(r *http.Request) string {
|
||||||
|
if auth := r.Header.Get("Authorization"); len(auth) > 7 && auth[:7] == "Bearer " {
|
||||||
|
return auth[7:]
|
||||||
|
}
|
||||||
|
return r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
16
server/internal/api/context.go
Normal file
16
server/internal/api/context.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const claimsKey contextKey = iota
|
||||||
|
|
||||||
|
func contextWithClaims(ctx context.Context, c *jwtClaims) context.Context {
|
||||||
|
return context.WithValue(ctx, claimsKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimsFromContext(r interface{ Context() context.Context }) (*jwtClaims, bool) {
|
||||||
|
c, ok := r.Context().Value(claimsKey).(*jwtClaims)
|
||||||
|
return c, ok && c != nil
|
||||||
|
}
|
||||||
301
server/internal/api/handlers.go
Normal file
301
server/internal/api/handlers.go
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containarr/server/internal/broker"
|
||||||
|
grpcgateway "github.com/containarr/server/internal/grpc"
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
"github.com/containarr/server/internal/store"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *store.Store
|
||||||
|
registry *grpcgateway.Registry
|
||||||
|
broker *broker.Broker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(s *store.Store, r *grpcgateway.Registry, b *broker.Broker) *Handler {
|
||||||
|
return &Handler{store: s, registry: r, broker: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
persisted, err := h.store.ListAgents()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "store error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
liveByID := map[string]*grpcgateway.AgentState{}
|
||||||
|
for _, s := range h.registry.List() {
|
||||||
|
liveByID[s.ID] = s
|
||||||
|
}
|
||||||
|
out := make([]agentDTO, 0, len(persisted))
|
||||||
|
for _, a := range persisted {
|
||||||
|
dto := agentDTO{
|
||||||
|
ID: a.ID,
|
||||||
|
Hostname: a.Hostname,
|
||||||
|
Alias: a.Alias,
|
||||||
|
IPAddress: a.IPAddress,
|
||||||
|
Arch: a.Arch,
|
||||||
|
OS: a.OS,
|
||||||
|
}
|
||||||
|
if live, ok := liveByID[a.ID]; ok {
|
||||||
|
dto.Online = true
|
||||||
|
dto.IPAddress = live.IPAddress
|
||||||
|
dto.LastSeenAt = live.LastSeenAt
|
||||||
|
}
|
||||||
|
out = append(out, dto)
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID := chi.URLParam(r, "agentID")
|
||||||
|
var body struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.UpdateAgentAlias(agentID, body.Alias); err != nil {
|
||||||
|
http.Error(w, "store error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.registry.UpdateAlias(agentID, body.Alias)
|
||||||
|
|
||||||
|
a, err := h.store.GetAgent(agentID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, agentDTO{
|
||||||
|
ID: a.ID,
|
||||||
|
Hostname: a.Hostname,
|
||||||
|
Alias: a.Alias,
|
||||||
|
IPAddress: a.IPAddress,
|
||||||
|
Arch: a.Arch,
|
||||||
|
OS: a.OS,
|
||||||
|
Online: a.Online,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Containers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) ListContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type containerDTO struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Container *agentv1.ContainerInfo `json:"container"`
|
||||||
|
}
|
||||||
|
var out []containerDTO
|
||||||
|
for _, agent := range h.registry.List() {
|
||||||
|
for _, c := range agent.Containers {
|
||||||
|
out = append(out, containerDTO{
|
||||||
|
AgentID: agent.ID,
|
||||||
|
Hostname: agent.Hostname,
|
||||||
|
Alias: agent.Alias,
|
||||||
|
IPAddress: agent.IPAddress,
|
||||||
|
Container: c,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ContainerAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID := chi.URLParam(r, "agentID")
|
||||||
|
containerID := chi.URLParam(r, "containerID")
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action, ok := map[string]agentv1.ContainerAction{
|
||||||
|
"start": agentv1.ContainerAction_CONTAINER_ACTION_START,
|
||||||
|
"stop": agentv1.ContainerAction_CONTAINER_ACTION_STOP,
|
||||||
|
"restart": agentv1.ContainerAction_CONTAINER_ACTION_RESTART,
|
||||||
|
"remove": agentv1.ContainerAction_CONTAINER_ACTION_REMOVE,
|
||||||
|
}[body.Action]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdID := uuid.NewString()
|
||||||
|
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
|
||||||
|
Payload: &agentv1.ServerMessage_ContainerCmd{
|
||||||
|
ContainerCmd: &agentv1.ContainerCommand{
|
||||||
|
CommandId: cmdID,
|
||||||
|
ContainerId: containerID,
|
||||||
|
Action: action,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !sent {
|
||||||
|
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"command_id": cmdID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent token provisioning ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Hostname == "" {
|
||||||
|
http.Error(w, "hostname required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
token := uuid.NewString()
|
||||||
|
if err := h.store.CreateAgentToken(id, token, body.Hostname); err != nil {
|
||||||
|
http.Error(w, "store error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"agent_id": id, "token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Container log stream ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) LogsWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID := chi.URLParam(r, "agentID")
|
||||||
|
containerID := chi.URLParam(r, "containerID")
|
||||||
|
|
||||||
|
follow := r.URL.Query().Get("follow") != "false"
|
||||||
|
tail := int32(100)
|
||||||
|
if t := r.URL.Query().Get("tail"); t != "" {
|
||||||
|
if n, err := strconv.Atoi(t); err == nil && n > 0 {
|
||||||
|
tail = int32(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
|
||||||
|
Payload: &agentv1.ServerMessage_StreamLogs{
|
||||||
|
StreamLogs: &agentv1.StreamLogsCommand{
|
||||||
|
CommandId: uuid.NewString(),
|
||||||
|
ContainerId: containerID,
|
||||||
|
Follow: follow,
|
||||||
|
Tail: tail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !sent {
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"agent not connected"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := h.broker.Subscribe()
|
||||||
|
defer h.broker.Unsubscribe(sub)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case raw, ok := <-sub:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &envelope) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if envelope.Type != "log.chunk" || envelope.AgentID != agentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chunk struct {
|
||||||
|
ContainerID string `json:"container_id"`
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(envelope.Payload, &chunk) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chunk.ContainerID != containerID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, _ := json.Marshal(map[string]string{
|
||||||
|
"stream": chunk.Stream,
|
||||||
|
"line": string(chunk.Data),
|
||||||
|
})
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket event stream ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) EventsWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
sub := h.broker.Subscribe()
|
||||||
|
defer h.broker.Unsubscribe(sub)
|
||||||
|
|
||||||
|
for data := range sub {
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonOK(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
35
server/internal/api/router.go
Normal file
35
server/internal/api/router.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRouter(h *Handler) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Post("/auth/login", h.Login)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(requireJWT)
|
||||||
|
r.Post("/auth/change-password", h.ChangePassword)
|
||||||
|
r.Get("/agents", h.ListAgents)
|
||||||
|
r.Post("/agents/token", h.CreateAgentToken)
|
||||||
|
r.Patch("/agents/{agentID}", h.UpdateAgent)
|
||||||
|
r.Get("/containers", h.ListContainers)
|
||||||
|
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
|
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
|
||||||
|
r.Get("/events", h.EventsWS)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Handle("/*", http.FileServer(http.Dir("./web/dist")))
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
49
server/internal/auth/auth.go
Normal file
49
server/internal/auth/auth.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"uid"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(secret string) *Service {
|
||||||
|
return &Service{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Sign(userID string) (string, error) {
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Verify(tokenStr string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return s.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
64
server/internal/auth/auth_test.go
Normal file
64
server/internal/auth/auth_test.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignAndVerify(t *testing.T) {
|
||||||
|
svc := New("test-secret")
|
||||||
|
|
||||||
|
token, err := svc.Sign("user42")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("expected non-empty token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := svc.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "user42" {
|
||||||
|
t.Errorf("expected UserID 'user42', got %q", claims.UserID)
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt == nil || claims.ExpiresAt.Before(time.Now()) {
|
||||||
|
t.Error("token should not be expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_InvalidToken(t *testing.T) {
|
||||||
|
svc := New("test-secret")
|
||||||
|
_, err := svc.Verify("not.a.valid.token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_WrongSecret(t *testing.T) {
|
||||||
|
svc1 := New("secret-a")
|
||||||
|
svc2 := New("secret-b")
|
||||||
|
|
||||||
|
token, err := svc1.Sign("user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc2.Verify(token)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when verifying with different secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_TamperedToken(t *testing.T) {
|
||||||
|
svc := New("test-secret")
|
||||||
|
token, _ := svc.Sign("admin")
|
||||||
|
|
||||||
|
// Append garbage to corrupt the signature.
|
||||||
|
tampered := token + "x"
|
||||||
|
_, err := svc.Verify(tampered)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for tampered token")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
server/internal/broker/broker.go
Normal file
55
server/internal/broker/broker.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is a JSON-serialisable message pushed to WebSocket clients.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
|
Payload any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type subscriber chan []byte
|
||||||
|
|
||||||
|
// Broker fan-outs events to all registered WebSocket subscribers.
|
||||||
|
type Broker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
subs map[subscriber]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Broker {
|
||||||
|
return &Broker{subs: make(map[subscriber]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Subscribe() subscriber {
|
||||||
|
ch := make(subscriber, 32)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subs[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Unsubscribe(ch subscriber) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.subs, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Publish(evt Event) {
|
||||||
|
data, err := json.Marshal(evt)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
for ch := range b.subs {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default: // drop if subscriber is slow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
server/internal/broker/broker_test.go
Normal file
123
server/internal/broker/broker_test.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscribePublishUnsubscribe(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
|
||||||
|
sub := b.Subscribe()
|
||||||
|
|
||||||
|
evt := Event{Type: "test.event", AgentID: "agent1", Payload: map[string]string{"k": "v"}}
|
||||||
|
b.Publish(evt)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case raw := <-sub:
|
||||||
|
var got Event
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if got.Type != "test.event" || got.AgentID != "agent1" {
|
||||||
|
t.Errorf("unexpected event: %+v", got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for event")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
// channel must be closed after unsubscribe
|
||||||
|
select {
|
||||||
|
case _, ok := <-sub:
|
||||||
|
if ok {
|
||||||
|
t.Error("expected channel to be closed")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for channel close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleSubscribers(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
|
||||||
|
sub1 := b.Subscribe()
|
||||||
|
sub2 := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub1)
|
||||||
|
defer b.Unsubscribe(sub2)
|
||||||
|
|
||||||
|
b.Publish(Event{Type: "ping", Payload: nil})
|
||||||
|
|
||||||
|
for i, sub := range []subscriber{sub1, sub2} {
|
||||||
|
select {
|
||||||
|
case <-sub:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("subscriber %d did not receive event", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishDropsWhenSubscriberSlow(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
|
||||||
|
// Channel size is 32; fill it up and then publish one more — it must not block.
|
||||||
|
sub := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
b.Publish(Event{Type: "flood", Payload: i})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This extra publish must return immediately (dropped, not block).
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
b.Publish(Event{Type: "dropped", Payload: nil})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Publish blocked on slow subscriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishNoSubscribers(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
// Should not panic or block
|
||||||
|
b.Publish(Event{Type: "nobody", Payload: nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInvalidPayload(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
sub := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
// json.Marshal of a channel fails — Publish must not send anything.
|
||||||
|
b.Publish(Event{Type: "bad", Payload: make(chan int)})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-sub:
|
||||||
|
t.Error("should not have received a message for an unmarshalable event")
|
||||||
|
default:
|
||||||
|
// correct: nothing sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribeRemovesFromBroker(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
sub := b.Subscribe()
|
||||||
|
b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
// After unsubscribe the broker's map should be empty.
|
||||||
|
b.mu.RLock()
|
||||||
|
n := len(b.subs)
|
||||||
|
b.mu.RUnlock()
|
||||||
|
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("expected 0 subscribers after unsubscribe, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
server/internal/grpc/gateway.go
Normal file
137
server/internal/grpc/gateway.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/containarr/server/internal/broker"
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
"github.com/containarr/server/internal/store"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/peer"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Gateway struct {
|
||||||
|
agentv1.UnimplementedAgentGatewayServer
|
||||||
|
store *store.Store
|
||||||
|
registry *Registry
|
||||||
|
broker *broker.Broker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGateway(s *store.Store, r *Registry, b *broker.Broker) *Gateway {
|
||||||
|
return &Gateway{store: s, registry: r, broker: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
|
||||||
|
if err := stream.SendHeader(metadata.MD{}); err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "send header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hs := first.GetHandshake()
|
||||||
|
if hs == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "first message must be AgentHandshake")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := g.store.AgentByToken(hs.Token)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "unknown agent token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract peer IP from the gRPC connection.
|
||||||
|
ipAddress := ""
|
||||||
|
if p, ok := peer.FromContext(stream.Context()); ok {
|
||||||
|
if host, _, err := net.SplitHostPort(p.Addr.String()); err == nil {
|
||||||
|
ipAddress = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agentID := existing.ID
|
||||||
|
slog.Info("agent connected", "id", agentID, "hostname", hs.Hostname, "ip", ipAddress)
|
||||||
|
|
||||||
|
state := g.registry.Register(agentID, hs.Hostname, existing.Alias, ipAddress, hs.Arch, hs.Os)
|
||||||
|
_ = g.store.UpsertAgent(&store.Agent{
|
||||||
|
ID: agentID,
|
||||||
|
Token: hs.Token,
|
||||||
|
Hostname: hs.Hostname,
|
||||||
|
Alias: existing.Alias,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
Arch: hs.Arch,
|
||||||
|
OS: hs.Os,
|
||||||
|
Online: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
g.broker.Publish(broker.Event{
|
||||||
|
Type: "agent.connected",
|
||||||
|
AgentID: agentID,
|
||||||
|
Payload: map[string]string{"hostname": hs.Hostname},
|
||||||
|
})
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
g.registry.Deregister(agentID)
|
||||||
|
_ = g.store.SetAgentOffline(agentID)
|
||||||
|
g.broker.Publish(broker.Event{Type: "agent.disconnected", AgentID: agentID, Payload: nil})
|
||||||
|
slog.Info("agent disconnected", "id", agentID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for msg := range state.cmdCh {
|
||||||
|
if err := stream.Send(msg); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p := msg.Payload.(type) {
|
||||||
|
case *agentv1.AgentMessage_Snapshot:
|
||||||
|
g.registry.UpdateContainers(agentID, p.Snapshot.Containers)
|
||||||
|
g.broker.Publish(broker.Event{
|
||||||
|
Type: "containers.updated",
|
||||||
|
AgentID: agentID,
|
||||||
|
Payload: p.Snapshot.Containers,
|
||||||
|
})
|
||||||
|
|
||||||
|
case *agentv1.AgentMessage_Result:
|
||||||
|
g.broker.Publish(broker.Event{
|
||||||
|
Type: "command.result",
|
||||||
|
AgentID: agentID,
|
||||||
|
Payload: p.Result,
|
||||||
|
})
|
||||||
|
|
||||||
|
case *agentv1.AgentMessage_LogChunk:
|
||||||
|
g.broker.Publish(broker.Event{
|
||||||
|
Type: "log.chunk",
|
||||||
|
AgentID: agentID,
|
||||||
|
Payload: p.LogChunk,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommandID() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
105
server/internal/grpc/registry.go
Normal file
105
server/internal/grpc/registry.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentState struct {
|
||||||
|
ID string
|
||||||
|
Hostname string
|
||||||
|
Alias string
|
||||||
|
IPAddress string
|
||||||
|
Arch string
|
||||||
|
OS string
|
||||||
|
LastSeenAt time.Time
|
||||||
|
Containers []*agentv1.ContainerInfo
|
||||||
|
|
||||||
|
cmdCh chan *agentv1.ServerMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[string]*AgentState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{agents: make(map[string]*AgentState)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Register(id, hostname, alias, ipAddress, arch, os string) *AgentState {
|
||||||
|
state := &AgentState{
|
||||||
|
ID: id,
|
||||||
|
Hostname: hostname,
|
||||||
|
Alias: alias,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
Arch: arch,
|
||||||
|
OS: os,
|
||||||
|
cmdCh: make(chan *agentv1.ServerMessage, 16),
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.agents[id] = state
|
||||||
|
r.mu.Unlock()
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Deregister(id string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
if s, ok := r.agents[id]; ok {
|
||||||
|
close(s.cmdCh)
|
||||||
|
delete(r.agents, id)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(id string) (*AgentState, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
s, ok := r.agents[id]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) List() []*AgentState {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]*AgentState, 0, len(r.agents))
|
||||||
|
for _, s := range r.agents {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) UpdateContainers(id string, containers []*agentv1.ContainerInfo) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if s, ok := r.agents[id]; ok {
|
||||||
|
s.Containers = containers
|
||||||
|
s.LastSeenAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAlias refreshes the alias for a live agent (called after an admin update).
|
||||||
|
func (r *Registry) UpdateAlias(id, alias string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if s, ok := r.agents[id]; ok {
|
||||||
|
s.Alias = alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Send(agentID string, msg *agentv1.ServerMessage) bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
s, ok := r.agents[agentID]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.cmdCh <- msg:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
155
server/internal/grpc/registry_test.go
Normal file
155
server/internal/grpc/registry_test.go
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentv1 "github.com/containarr/server/internal/proto/agentv1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegisterAndGet(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
|
||||||
|
state := r.Register("id1", "hostname1", "alias1", "10.0.0.1", "amd64", "linux")
|
||||||
|
if state == nil {
|
||||||
|
t.Fatal("Register returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := r.Get("id1")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Get returned false for registered agent")
|
||||||
|
}
|
||||||
|
if got.ID != "id1" || got.Hostname != "hostname1" || got.Alias != "alias1" {
|
||||||
|
t.Errorf("unexpected state: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_NotFound(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
_, ok := r.Get("nonexistent")
|
||||||
|
if ok {
|
||||||
|
t.Error("expected false for unknown agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeregister(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register("id1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
r.Deregister("id1")
|
||||||
|
|
||||||
|
_, ok := r.Get("id1")
|
||||||
|
if ok {
|
||||||
|
t.Error("agent should not exist after Deregister")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeregister_NotExist(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
// must not panic
|
||||||
|
r.Deregister("ghost")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
|
||||||
|
if len(r.List()) != 0 {
|
||||||
|
t.Error("expected empty list")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Register("a1", "h1", "", "", "", "")
|
||||||
|
r.Register("a2", "h2", "", "", "", "")
|
||||||
|
|
||||||
|
if len(r.List()) != 2 {
|
||||||
|
t.Errorf("expected 2 agents, got %d", len(r.List()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateContainers(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register("id1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
containers := []*agentv1.ContainerInfo{
|
||||||
|
{Id: "c1", Name: "web"},
|
||||||
|
{Id: "c2", Name: "db"},
|
||||||
|
}
|
||||||
|
r.UpdateContainers("id1", containers)
|
||||||
|
|
||||||
|
got, _ := r.Get("id1")
|
||||||
|
if len(got.Containers) != 2 {
|
||||||
|
t.Errorf("expected 2 containers, got %d", len(got.Containers))
|
||||||
|
}
|
||||||
|
if got.LastSeenAt.Before(before) {
|
||||||
|
t.Error("LastSeenAt should have been updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateContainers_UnknownAgent(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
// must not panic
|
||||||
|
r.UpdateContainers("ghost", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAlias(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register("id1", "h", "old-alias", "ip", "arch", "os")
|
||||||
|
|
||||||
|
r.UpdateAlias("id1", "new-alias")
|
||||||
|
|
||||||
|
got, _ := r.Get("id1")
|
||||||
|
if got.Alias != "new-alias" {
|
||||||
|
t.Errorf("expected 'new-alias', got %q", got.Alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAlias_UnknownAgent(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
// must not panic
|
||||||
|
r.UpdateAlias("ghost", "alias")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
state := r.Register("id1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
msg := &agentv1.ServerMessage{}
|
||||||
|
ok := r.Send("id1", msg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Send returned false for connected agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the channel to verify the message arrived.
|
||||||
|
select {
|
||||||
|
case got := <-state.cmdCh:
|
||||||
|
if got != msg {
|
||||||
|
t.Error("received wrong message")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out reading from cmdCh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_UnknownAgent(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
ok := r.Send("ghost", &agentv1.ServerMessage{})
|
||||||
|
if ok {
|
||||||
|
t.Error("Send should return false for unknown agent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_FullChannel(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register("id1", "h", "a", "ip", "arch", "os")
|
||||||
|
|
||||||
|
// Fill the buffer (size 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
r.Send("id1", &agentv1.ServerMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next send on a full channel should return false
|
||||||
|
ok := r.Send("id1", &agentv1.ServerMessage{})
|
||||||
|
if ok {
|
||||||
|
t.Error("Send should return false when channel is full")
|
||||||
|
}
|
||||||
|
}
|
||||||
183
server/internal/store/store.go
Normal file
183
server/internal/store/store.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
ID string
|
||||||
|
Token string
|
||||||
|
Hostname string
|
||||||
|
Alias string
|
||||||
|
IPAddress string
|
||||||
|
Arch string
|
||||||
|
OS string
|
||||||
|
LastSeenAt time.Time
|
||||||
|
Online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (*Store, error) {
|
||||||
|
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=on")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Store{db: db}
|
||||||
|
return s, s.migrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
alias TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
arch TEXT NOT NULL DEFAULT '',
|
||||||
|
os TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
online INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Idempotent — ignore error if column already exists.
|
||||||
|
for _, col := range []string{
|
||||||
|
`ALTER TABLE agents ADD COLUMN alias TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE agents ADD COLUMN ip_address TEXT NOT NULL DEFAULT ''`,
|
||||||
|
} {
|
||||||
|
s.db.Exec(col)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
func (s *Store) UpsertAgent(a *Agent) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO agents (id, token, hostname, alias, ip_address, arch, os, last_seen_at, online)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(token) DO UPDATE SET
|
||||||
|
hostname = excluded.hostname,
|
||||||
|
ip_address = excluded.ip_address,
|
||||||
|
arch = excluded.arch,
|
||||||
|
os = excluded.os,
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
online = excluded.online
|
||||||
|
`, a.ID, a.Token, a.Hostname, a.Alias, a.IPAddress, a.Arch, a.OS, a.LastSeenAt, boolToInt(a.Online))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AgentByToken(token string) (*Agent, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT id, token, hostname, alias, ip_address, arch, os, last_seen_at, online
|
||||||
|
FROM agents WHERE token = ?`, token)
|
||||||
|
return scanAgent(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAgent(id string) (*Agent, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT id, token, hostname, alias, ip_address, arch, os, last_seen_at, online
|
||||||
|
FROM agents WHERE id = ?`, id)
|
||||||
|
return scanAgent(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAgents() ([]*Agent, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, token, hostname, alias, ip_address, arch, os, last_seen_at, online
|
||||||
|
FROM agents ORDER BY hostname`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var agents []*Agent
|
||||||
|
for rows.Next() {
|
||||||
|
a := &Agent{}
|
||||||
|
var online int
|
||||||
|
var lastSeen sql.NullTime
|
||||||
|
if err := rows.Scan(&a.ID, &a.Token, &a.Hostname, &a.Alias, &a.IPAddress, &a.Arch, &a.OS, &lastSeen, &online); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
a.LastSeenAt = lastSeen.Time
|
||||||
|
}
|
||||||
|
a.Online = online == 1
|
||||||
|
agents = append(agents, a)
|
||||||
|
}
|
||||||
|
return agents, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetAgentOffline(id string) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE agents SET online = 0 WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAgentToken(id, token, hostname string) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT OR IGNORE INTO agents (id, token, hostname, arch, os, online)
|
||||||
|
VALUES (?, ?, ?, '', '', 0)
|
||||||
|
`, id, token, hostname)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAgentAlias(id, alias string) error {
|
||||||
|
_, err := s.db.Exec(`UPDATE agents SET alias = ? WHERE id = ?`, alias, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) GetUserHash(username string) (string, error) {
|
||||||
|
var hash string
|
||||||
|
err := s.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, username).Scan(&hash)
|
||||||
|
return hash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertUser(username, hash string) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash
|
||||||
|
`, username, hash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserExists(username string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE username = ?`, username).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAgent(row *sql.Row) (*Agent, error) {
|
||||||
|
a := &Agent{}
|
||||||
|
var online int
|
||||||
|
var lastSeen sql.NullTime
|
||||||
|
err := row.Scan(&a.ID, &a.Token, &a.Hostname, &a.Alias, &a.IPAddress, &a.Arch, &a.OS, &lastSeen, &online)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
a.LastSeenAt = lastSeen.Time
|
||||||
|
}
|
||||||
|
a.Online = online == 1
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
208
server/internal/store/store_test.go
Normal file
208
server/internal/store/store_test.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open in-memory store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestUpsertAndGetUserHash(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.UpsertUser("alice", "hash123"); err != nil {
|
||||||
|
t.Fatalf("UpsertUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := s.GetUserHash("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserHash: %v", err)
|
||||||
|
}
|
||||||
|
if h != "hash123" {
|
||||||
|
t.Errorf("expected hash123, got %q", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserHash_NotFound(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
_, err := s.GetUserHash("nobody")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing user, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertUser_Update(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.UpsertUser("alice", "first"); err != nil {
|
||||||
|
t.Fatalf("UpsertUser: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.UpsertUser("alice", "second"); err != nil {
|
||||||
|
t.Fatalf("UpsertUser update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := s.GetUserHash("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserHash: %v", err)
|
||||||
|
}
|
||||||
|
if h != "second" {
|
||||||
|
t.Errorf("expected second, got %q", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserExists(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
ok, err := s.UserExists("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserExists: %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Error("expected false for non-existent user")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.UpsertUser("alice", "hash")
|
||||||
|
|
||||||
|
ok, err = s.UserExists("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserExists: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected true after insert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCreateAgentToken(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if err := s.CreateAgentToken("id1", "tok1", "host1"); err != nil {
|
||||||
|
t.Fatalf("CreateAgentToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.AgentByToken("tok1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AgentByToken: %v", err)
|
||||||
|
}
|
||||||
|
if a.ID != "id1" || a.Hostname != "host1" {
|
||||||
|
t.Errorf("unexpected agent: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentByToken_NotFound(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
_, err := s.AgentByToken("doesnotexist")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertAgent(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
a := &Agent{
|
||||||
|
ID: "agent1",
|
||||||
|
Token: "tok1",
|
||||||
|
Hostname: "myhost",
|
||||||
|
Alias: "myalias",
|
||||||
|
IPAddress: "10.0.0.1",
|
||||||
|
Arch: "amd64",
|
||||||
|
OS: "linux",
|
||||||
|
Online: true,
|
||||||
|
}
|
||||||
|
if err := s.UpsertAgent(a); err != nil {
|
||||||
|
t.Fatalf("UpsertAgent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAgent("agent1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAgent: %v", err)
|
||||||
|
}
|
||||||
|
if got.Hostname != "myhost" || got.Alias != "myalias" {
|
||||||
|
t.Errorf("unexpected agent: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAgents(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
_ = s.CreateAgentToken("a1", "t1", "host-b")
|
||||||
|
_ = s.CreateAgentToken("a2", "t2", "host-a")
|
||||||
|
|
||||||
|
agents, err := s.ListAgents()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAgents: %v", err)
|
||||||
|
}
|
||||||
|
if len(agents) != 2 {
|
||||||
|
t.Fatalf("expected 2 agents, got %d", len(agents))
|
||||||
|
}
|
||||||
|
// ORDER BY hostname: host-a < host-b
|
||||||
|
if agents[0].Hostname != "host-a" || agents[1].Hostname != "host-b" {
|
||||||
|
t.Errorf("unexpected order: %v %v", agents[0].Hostname, agents[1].Hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAgentOffline(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
_ = s.UpsertAgent(&Agent{
|
||||||
|
ID: "a1",
|
||||||
|
Token: "t1",
|
||||||
|
Hostname: "h1",
|
||||||
|
Online: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := s.SetAgentOffline("a1"); err != nil {
|
||||||
|
t.Fatalf("SetAgentOffline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.GetAgent("a1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAgent: %v", err)
|
||||||
|
}
|
||||||
|
if a.Online {
|
||||||
|
t.Error("expected Online=false after SetAgentOffline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAgentAlias(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
_ = s.CreateAgentToken("a1", "t1", "host1")
|
||||||
|
|
||||||
|
if err := s.UpdateAgentAlias("a1", "newalias"); err != nil {
|
||||||
|
t.Fatalf("UpdateAgentAlias: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.GetAgent("a1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAgent: %v", err)
|
||||||
|
}
|
||||||
|
if a.Alias != "newalias" {
|
||||||
|
t.Errorf("expected alias 'newalias', got %q", a.Alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAgentToken_IdempotentIgnore(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
// INSERT OR IGNORE — second call should not error
|
||||||
|
if err := s.CreateAgentToken("id1", "tok1", "h1"); err != nil {
|
||||||
|
t.Fatalf("first call: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.CreateAgentToken("id1", "tok1", "h1"); err != nil {
|
||||||
|
t.Fatalf("second call (should be idempotent): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
8613
web/package-lock.json
generated
Normal file
8613
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
web/package.json
Normal file
33
web/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "containarr-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.1.4",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^4.1.6",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
86
web/src/app.css
Normal file
86
web/src/app.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { background-color: #050f0c; }
|
||||||
|
|
||||||
|
code, .font-mono {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
||||||
|
|
||||||
|
::selection { background: rgba(59,130,246,0.3); color: #e8edf8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.dot-running {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 7px rgba(52,211,153,0.6);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-exited {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f87171;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-other {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fbbf24;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-online {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #34d399;
|
||||||
|
box-shadow: 0 0 8px rgba(52,211,153,0.55);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot-offline {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1e2f48;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: rgba(5,15,12,0.85);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid rgba(16,185,129,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #071410;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(52,211,153,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #3d6b5a;
|
||||||
|
transition: color 0.15s, background-color 0.15s;
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
color: #a7f3d0;
|
||||||
|
background: rgba(16,185,129,0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
web/src/app.html
Normal file
15
web/src/app.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover" class="bg-slate-900 text-slate-100">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
web/src/lib/LogModal.svelte
Normal file
121
web/src/lib/LogModal.svelte
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
|
import { connectLogs } from "./api";
|
||||||
|
|
||||||
|
let {
|
||||||
|
agentId,
|
||||||
|
containerId,
|
||||||
|
containerName,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
containerId: string;
|
||||||
|
containerName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
stream: string;
|
||||||
|
line: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = $state<LogLine[]>([]);
|
||||||
|
let autoScroll = $state(true);
|
||||||
|
let logEl = $state<HTMLElement | null>(null);
|
||||||
|
let disconnect: (() => void) | null = null;
|
||||||
|
|
||||||
|
function stripAnsi(str: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
if (!autoScroll || !logEl) return;
|
||||||
|
await tick();
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (!logEl) return;
|
||||||
|
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
|
||||||
|
autoScroll = atBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
disconnect = connectLogs(agentId, containerId, async (msg) => {
|
||||||
|
if (msg.line) {
|
||||||
|
lines.push({ stream: msg.stream, line: stripAnsi(msg.line) });
|
||||||
|
if (lines.length > 2000) lines = lines.slice(-2000);
|
||||||
|
await scrollToBottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => disconnect?.());
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKey} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex flex-col bg-black/70 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Logs — {containerName}"
|
||||||
|
>
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<div class="flex flex-col m-4 md:m-8 flex-1 min-h-0 card overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/[0.07] shrink-0">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-signal-green animate-pulse"></span>
|
||||||
|
<span class="font-mono text-sm font-semibold text-slate-200">{containerName}</span>
|
||||||
|
<span class="text-xs text-slate-600 ml-1">logs</span>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-slate-500 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={autoScroll}
|
||||||
|
class="accent-emerald w-3 h-3"
|
||||||
|
/>
|
||||||
|
Auto-scroll
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onclick={() => { lines = []; }}
|
||||||
|
class="nav-btn text-xs px-2"
|
||||||
|
title="Effacer"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
<button onclick={onClose} class="nav-btn" title="Fermer (Échap)">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log output -->
|
||||||
|
<div
|
||||||
|
bind:this={logEl}
|
||||||
|
onscroll={onScroll}
|
||||||
|
class="flex-1 overflow-y-auto font-mono text-xs leading-5 p-4 space-y-px bg-abyss-900"
|
||||||
|
>
|
||||||
|
{#if lines.length === 0}
|
||||||
|
<p class="text-slate-700 italic">En attente des logs…</p>
|
||||||
|
{:else}
|
||||||
|
{#each lines as { stream, line }, i (i)}
|
||||||
|
<div class="whitespace-pre-wrap break-all {stream === 'stderr' ? 'text-signal-red/80' : 'text-slate-300'}">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
138
web/src/lib/api.ts
Normal file
138
web/src/lib/api.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { getToken, clearToken } from "./auth";
|
||||||
|
|
||||||
|
export interface ContainerPort {
|
||||||
|
host_port: number;
|
||||||
|
container_port: number;
|
||||||
|
protocol: string;
|
||||||
|
host_ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
status: string;
|
||||||
|
state: string;
|
||||||
|
ports: ContainerPort[];
|
||||||
|
created_at: number;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
compose_project: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerEntry {
|
||||||
|
agent_id: string;
|
||||||
|
hostname: string;
|
||||||
|
alias: string;
|
||||||
|
ip_address: string;
|
||||||
|
container: ContainerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
alias: string;
|
||||||
|
ip_address: string;
|
||||||
|
arch: string;
|
||||||
|
os: string;
|
||||||
|
online: boolean;
|
||||||
|
last_seen_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = "/api/v1";
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const r = await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: { ...authHeaders(), ...(init.headers as Record<string, string> ?? {}) },
|
||||||
|
});
|
||||||
|
if (r.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
location.href = "/login";
|
||||||
|
throw new Error("Session expirée");
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgents(): Promise<Agent[]> {
|
||||||
|
const r = await apiFetch(`${BASE}/agents`);
|
||||||
|
if (!r.ok) throw new Error(`agents: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgent(id: string, alias: string): Promise<Agent> {
|
||||||
|
const r = await apiFetch(`${BASE}/agents/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ alias }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`update agent: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContainers(): Promise<ContainerEntry[]> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(`${BASE}/containers`, { signal: ac.signal });
|
||||||
|
if (!r.ok) throw new Error(`containers: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function containerAction(
|
||||||
|
agentId: string,
|
||||||
|
containerId: string,
|
||||||
|
action: "start" | "stop" | "restart" | "remove"
|
||||||
|
): Promise<{ command_id: string }> {
|
||||||
|
const r = await apiFetch(
|
||||||
|
`${BASE}/agents/${agentId}/containers/${containerId}/action`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`action failed: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectLogs(
|
||||||
|
agentId: string,
|
||||||
|
containerId: string,
|
||||||
|
onLine: (line: { stream: string; line: string }) => void,
|
||||||
|
tail = 200,
|
||||||
|
follow = true,
|
||||||
|
): () => void {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const token = getToken() ?? "";
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`${proto}://${location.host}/api/v1/agents/${agentId}/containers/${containerId}/logs?token=${encodeURIComponent(token)}&tail=${tail}&follow=${follow}`
|
||||||
|
);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try { onLine(JSON.parse(e.data)); } catch {}
|
||||||
|
};
|
||||||
|
return () => ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectEvents(
|
||||||
|
onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void
|
||||||
|
): () => void {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const token = getToken() ?? "";
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`${proto}://${location.host}/api/v1/events?token=${encodeURIComponent(token)}`
|
||||||
|
);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
onEvent(JSON.parse(e.data));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
return () => ws.close();
|
||||||
|
}
|
||||||
35
web/src/lib/auth.ts
Normal file
35
web/src/lib/auth.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const KEY = "containarr_token";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoggedIn(): boolean {
|
||||||
|
const t = getToken();
|
||||||
|
if (!t) return false;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(t.split(".")[1]));
|
||||||
|
return payload.exp * 1000 > Date.now();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<void> {
|
||||||
|
const r = await fetch("/api/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("Identifiants invalides");
|
||||||
|
const { token } = await r.json();
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
19
web/src/routes/+layout.svelte
Normal file
19
web/src/routes/+layout.svelte
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import "../app.css";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { isLoggedIn } from "$lib/auth";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const publicRoutes = ["/login"];
|
||||||
|
if (!publicRoutes.includes($page.url.pathname) && !isLoggedIn()) {
|
||||||
|
goto("/login");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
2
web/src/routes/+layout.ts
Normal file
2
web/src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
351
web/src/routes/+page.svelte
Normal file
351
web/src/routes/+page.svelte
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
fetchContainers,
|
||||||
|
containerAction,
|
||||||
|
connectEvents,
|
||||||
|
type ContainerEntry,
|
||||||
|
type ContainerPort,
|
||||||
|
} from "$lib/api";
|
||||||
|
import { clearToken } from "$lib/auth";
|
||||||
|
import LogModal from "$lib/LogModal.svelte";
|
||||||
|
|
||||||
|
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
function openLogs(agentId: string, containerId: string, name: string) {
|
||||||
|
logTarget = { agentId, containerId, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearToken();
|
||||||
|
goto("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = $state<ContainerEntry[] | null>(null);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let actionPending = $state<string | null>(null);
|
||||||
|
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const byAgent = $derived(
|
||||||
|
(entries ?? []).reduce<Record<string, ContainerEntry[]>>((acc, e) => {
|
||||||
|
(acc[e.agent_id] ??= []).push(e);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
let disconnect: (() => void) | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
entries = await fetchContainers() ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
loadError = e.message;
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAction(
|
||||||
|
agentId: string,
|
||||||
|
containerId: string,
|
||||||
|
action: "start" | "stop" | "restart" | "remove"
|
||||||
|
) {
|
||||||
|
actionPending = containerId;
|
||||||
|
try {
|
||||||
|
await containerAction(agentId, containerId, action);
|
||||||
|
showToast(`${action} envoyé`, true);
|
||||||
|
setTimeout(load, 1500);
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message, false);
|
||||||
|
} finally {
|
||||||
|
actionPending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string, ok: boolean) {
|
||||||
|
toast = { msg, ok };
|
||||||
|
setTimeout(() => (toast = null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load();
|
||||||
|
disconnect = connectEvents((evt) => {
|
||||||
|
if (evt.type === "containers.updated") load();
|
||||||
|
if (evt.type === "agent.connected" || evt.type === "agent.disconnected") load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => disconnect?.());
|
||||||
|
|
||||||
|
function uniquePorts(ports: ContainerPort[] | null) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (ports ?? []).filter(p => {
|
||||||
|
if (p.host_port <= 0) return false;
|
||||||
|
const key = `${p.host_port}:${p.container_port}:${p.protocol}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDotClass(state: string) {
|
||||||
|
if (state === "running") return "dot-running";
|
||||||
|
if (state === "exited") return "dot-exited";
|
||||||
|
return "dot-other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateTextClass(state: string) {
|
||||||
|
if (state === "running") return "text-signal-green";
|
||||||
|
if (state === "exited") return "text-signal-red";
|
||||||
|
return "text-signal-yellow";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Containarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if logTarget}
|
||||||
|
<LogModal
|
||||||
|
agentId={logTarget.agentId}
|
||||||
|
containerId={logTarget.containerId}
|
||||||
|
containerName={logTarget.name}
|
||||||
|
onClose={() => (logTarget = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
{#if toast}
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm
|
||||||
|
font-medium shadow-2xl border transition-all
|
||||||
|
{toast.ok
|
||||||
|
? 'bg-abyss-800 border-signal-green/30 text-signal-green'
|
||||||
|
: 'bg-abyss-800 border-signal-red/30 text-signal-red'}">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full {toast.ok ? 'bg-signal-green' : 'bg-signal-red'}"></span>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
|
||||||
|
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-1">
|
||||||
|
{#if entries !== null}
|
||||||
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
|
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<a href="/admin" class="nav-btn" title="Administration">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button onclick={load} class="nav-btn" title="Actualiser">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick={logout} class="nav-btn" title="Déconnexion">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="p-4 md:p-6 max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{#if entries === null}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||||
|
<div class="w-8 h-8 border-2 border-emerald/30 border-t-emerald-bright rounded-full animate-spin"></div>
|
||||||
|
<span class="text-sm">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||||
|
<span class="text-signal-red text-xl">⚠</span>
|
||||||
|
<p class="text-signal-red text-sm">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if Object.keys(byAgent).length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||||
|
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">Aucun agent connecté</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#each Object.entries(byAgent) as [_agentId, containers]}
|
||||||
|
{#if containers.length > 0}
|
||||||
|
{@const first = containers[0]}
|
||||||
|
<section class="mb-8">
|
||||||
|
|
||||||
|
<!-- Host header -->
|
||||||
|
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||||
|
<span class="dot-online"></span>
|
||||||
|
<h2 class="font-semibold text-slate-200 text-sm">
|
||||||
|
{first.alias || first.hostname}
|
||||||
|
</h2>
|
||||||
|
{#if first.alias}
|
||||||
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
|
{/if}
|
||||||
|
{#if first.ip_address}
|
||||||
|
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
|
||||||
|
border border-white/[0.06]">{first.ip_address}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
|
{containers.length} container{containers.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop table -->
|
||||||
|
<div class="hidden md:block card overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Nom</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Image</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">État</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Ports</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Projet</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each containers as { agent_id, container } (container.id)}
|
||||||
|
<tr class="border-b border-white/[0.04] last:border-0
|
||||||
|
hover:bg-white/[0.025] transition-colors group">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class={stateDotClass(container.state)}></span>
|
||||||
|
<span class="font-mono text-slate-200 text-xs font-medium">{container.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-[220px] truncate"
|
||||||
|
title={container.image}>{container.image}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-xs font-medium {stateTextClass(container.state)}">{container.state}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each uniquePorts(container.ports) as port}
|
||||||
|
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||||
|
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||||
|
{port.host_port}:{port.container_port}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600 font-mono">
|
||||||
|
{container.compose_project || "—"}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex justify-end gap-1.5">
|
||||||
|
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||||
|
loading: false,
|
||||||
|
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||||
|
{#if container.state !== "running"}
|
||||||
|
{@render ActionBtn({ label: "Start", variant: "green",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||||
|
{:else}
|
||||||
|
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||||
|
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div class="md:hidden space-y-2">
|
||||||
|
{#each containers as { agent_id, container } (container.id)}
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class={stateDotClass(container.state)}></span>
|
||||||
|
<span class="font-mono text-sm font-medium truncate text-slate-200">{container.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium shrink-0 {stateTextClass(container.state)}">{container.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="font-mono text-xs text-slate-600 truncate mb-3">{container.image}</p>
|
||||||
|
{#if uniquePorts(container.ports).length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mb-3">
|
||||||
|
{#each uniquePorts(container.ports) as port}
|
||||||
|
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||||
|
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||||
|
{port.host_port}:{port.container_port}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||||
|
loading: false,
|
||||||
|
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||||
|
{#if container.state !== "running"}
|
||||||
|
{@render ActionBtn({ label: "Start", variant: "green",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||||
|
{:else}
|
||||||
|
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||||
|
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||||
|
loading: actionPending === container.id,
|
||||||
|
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet ActionBtn({ label, variant, loading, onclick }: {
|
||||||
|
label: string;
|
||||||
|
variant: "green" | "ghost" | "cyan";
|
||||||
|
loading: boolean;
|
||||||
|
onclick: () => void;
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
{onclick}
|
||||||
|
disabled={loading}
|
||||||
|
class="px-2.5 py-1 rounded-lg text-xs font-medium transition-all disabled:opacity-40
|
||||||
|
{variant === 'green'
|
||||||
|
? 'bg-signal-green/10 hover:bg-signal-green/20 text-signal-green border border-signal-green/25'
|
||||||
|
: variant === 'cyan'
|
||||||
|
? 'bg-signal-cyan/10 hover:bg-signal-cyan/20 text-signal-cyan border border-signal-cyan/25'
|
||||||
|
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-400 border border-white/[0.08]'}"
|
||||||
|
>
|
||||||
|
{loading ? "…" : label}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
206
web/src/routes/admin/+page.svelte
Normal file
206
web/src/routes/admin/+page.svelte
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { fetchAgents, updateAgent, type Agent } from "$lib/api";
|
||||||
|
import { getToken } from "$lib/auth";
|
||||||
|
|
||||||
|
let agents = $state<Agent[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let saving = $state<string | null>(null);
|
||||||
|
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||||
|
let editing = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
agents = await fetchAgents();
|
||||||
|
for (const a of agents) editing[a.id] = a.alias;
|
||||||
|
} catch (e: any) {
|
||||||
|
loadError = e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveAlias(agent: Agent) {
|
||||||
|
saving = agent.id;
|
||||||
|
try {
|
||||||
|
const updated = await updateAgent(agent.id, editing[agent.id] ?? "");
|
||||||
|
agents = agents.map(a => a.id === updated.id ? { ...a, alias: updated.alias } : a);
|
||||||
|
showToast("Alias sauvegardé", true);
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message, false);
|
||||||
|
} finally {
|
||||||
|
saving = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string, ok: boolean) {
|
||||||
|
toast = { msg, ok };
|
||||||
|
setTimeout(() => (toast = null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password change ──────────────────────────────────────────────────────
|
||||||
|
let pwCurrent = $state("");
|
||||||
|
let pwNew = $state("");
|
||||||
|
let pwConfirm = $state("");
|
||||||
|
let pwSaving = $state(false);
|
||||||
|
let pwError = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function changePassword(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
pwError = null;
|
||||||
|
if (pwNew !== pwConfirm) { pwError = "Les mots de passe ne correspondent pas."; return; }
|
||||||
|
if (pwNew.length < 8) { pwError = "Minimum 8 caractères requis."; return; }
|
||||||
|
pwSaving = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/v1/auth/change-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify({ current_password: pwCurrent, new_password: pwNew }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.text()).trim() || `Erreur ${r.status}`);
|
||||||
|
pwCurrent = pwNew = pwConfirm = "";
|
||||||
|
showToast("Mot de passe modifié", true);
|
||||||
|
} catch (err: any) {
|
||||||
|
pwError = err.message;
|
||||||
|
} finally {
|
||||||
|
pwSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin — Containarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if toast}
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm
|
||||||
|
font-medium shadow-2xl border
|
||||||
|
{toast.ok
|
||||||
|
? 'bg-abyss-800 border-signal-green/30 text-signal-green'
|
||||||
|
: 'bg-abyss-800 border-signal-red/30 text-signal-red'}">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full {toast.ok ? 'bg-signal-green' : 'bg-signal-red'}"></span>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
|
||||||
|
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
|
||||||
|
<span class="text-slate-700">/</span>
|
||||||
|
<span class="text-sm text-slate-400">Administration</span>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="ml-auto nav-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-200 px-3">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="p-4 md:p-6 max-w-4xl mx-auto space-y-10">
|
||||||
|
|
||||||
|
<!-- Password section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-4">Sécurité</h2>
|
||||||
|
<div class="card p-5 max-w-sm">
|
||||||
|
<h3 class="text-sm font-semibold text-slate-200 mb-4">Changer le mot de passe</h3>
|
||||||
|
<form onsubmit={changePassword} class="space-y-3">
|
||||||
|
{#if pwError}
|
||||||
|
<p class="text-xs text-signal-red bg-signal-red/10 border border-signal-red/20
|
||||||
|
rounded-lg px-3 py-2">{pwError}</p>
|
||||||
|
{/if}
|
||||||
|
{#each [
|
||||||
|
{ id: "pw-c", label: "Mot de passe actuel", bind: "pwCurrent", val: pwCurrent, ac: "current-password" },
|
||||||
|
{ id: "pw-n", label: "Nouveau mot de passe", bind: "pwNew", val: pwNew, ac: "new-password" },
|
||||||
|
{ id: "pw-cf", label: "Confirmer", bind: "pwConfirm", val: pwConfirm, ac: "new-password" },
|
||||||
|
] as field}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-slate-500 mb-1" for={field.id}>{field.label}</label>
|
||||||
|
<input id={field.id} type="password" required
|
||||||
|
value={field.id === "pw-c" ? pwCurrent : field.id === "pw-n" ? pwNew : pwConfirm}
|
||||||
|
oninput={(e) => {
|
||||||
|
const v = (e.target as HTMLInputElement).value;
|
||||||
|
if (field.id === "pw-c") pwCurrent = v;
|
||||||
|
else if (field.id === "pw-n") pwNew = v;
|
||||||
|
else pwConfirm = v;
|
||||||
|
}}
|
||||||
|
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2 text-sm
|
||||||
|
text-slate-100 outline-none focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30
|
||||||
|
transition-all" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button type="submit" disabled={pwSaving}
|
||||||
|
class="w-full mt-1 bg-emerald hover:bg-emerald-bright disabled:opacity-50 text-white
|
||||||
|
text-sm font-medium py-2 rounded-lg transition-colors">
|
||||||
|
{pwSaving ? "Sauvegarde…" : "Mettre à jour"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Agents section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-4">Agents</h2>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="text-signal-red text-sm">{loadError}</p>
|
||||||
|
{:else if agents.length === 0}
|
||||||
|
<p class="text-slate-600 text-sm">Aucun agent enregistré.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Statut</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Hostname</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">IP</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Arch / OS</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Alias</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each agents as agent (agent.id)}
|
||||||
|
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class={agent.online ? "dot-online" : "dot-offline"}></span>
|
||||||
|
<span class="text-xs {agent.online ? 'text-signal-green' : 'text-slate-600'}">
|
||||||
|
{agent.online ? "En ligne" : "Hors ligne"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-400">{agent.hostname}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-500">{agent.ip_address || "—"}</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-slate-600 font-mono">{agent.arch || "—"} / {agent.os || "—"}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editing[agent.id]}
|
||||||
|
placeholder={agent.hostname}
|
||||||
|
class="bg-abyss-700 border border-white/[0.08] rounded-lg px-2.5 py-1.5
|
||||||
|
text-xs text-slate-100 placeholder-slate-700 outline-none w-36
|
||||||
|
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onclick={() => saveAlias(agent)}
|
||||||
|
disabled={saving === agent.id || editing[agent.id] === agent.alias}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all disabled:opacity-30
|
||||||
|
bg-emerald/10 hover:bg-emerald/20 text-emerald border border-emerald/20">
|
||||||
|
{saving === agent.id ? "…" : "Sauvegarder"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
94
web/src/routes/login/+page.svelte
Normal file
94
web/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { login } from "$lib/auth";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
let username = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = null;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
goto("/");
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Connexion — Containarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-[360px]">
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex flex-col items-center gap-3 mb-8">
|
||||||
|
<img src="/icon-192.png" alt="Containarr" class="w-14 h-14 rounded-2xl shadow-glow-emerald" />
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-lg font-semibold text-slate-100 tracking-tight">Containarr</h1>
|
||||||
|
<p class="text-xs text-slate-600 mt-0.5">Gestionnaire de containers Docker</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<form onsubmit={submit} class="card p-6 space-y-4">
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-signal-red/10
|
||||||
|
border border-signal-red/20 text-signal-red text-xs">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-xs font-medium text-slate-500" for="username">Utilisateur</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
bind:value={username}
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||||
|
text-slate-100 placeholder-slate-700 outline-none
|
||||||
|
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-xs font-medium text-slate-500" for="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||||
|
text-slate-100 placeholder-slate-700 outline-none
|
||||||
|
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full mt-2 bg-emerald hover:bg-emerald-bright disabled:opacity-50
|
||||||
|
text-white text-sm font-medium py-2.5 rounded-lg transition-colors
|
||||||
|
shadow-glow-emerald"
|
||||||
|
>
|
||||||
|
{loading ? "Connexion…" : "Se connecter"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
147
web/src/tests/LogModal.test.ts
Normal file
147
web/src/tests/LogModal.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LogModal.svelte
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Mount the component with @testing-library/svelte
|
||||||
|
* - Use a MockWebSocket to simulate incoming log messages
|
||||||
|
* - Verify DOM output and interaction (close button, Escape key, clear button)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||||
|
import LogModal from "../lib/LogModal.svelte";
|
||||||
|
|
||||||
|
// ── WebSocket mock ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
static last: MockWebSocket | null = null;
|
||||||
|
url: string;
|
||||||
|
onmessage: ((e: { data: string }) => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
MockWebSocket.last = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() { this.closed = true; }
|
||||||
|
|
||||||
|
/** Helper: push a log line */
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
MockWebSocket.last = null;
|
||||||
|
localStorage.setItem("containarr_token", "test-token");
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, "location", {
|
||||||
|
value: { protocol: "http:", host: "localhost" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Default props ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
agentId: "agent1",
|
||||||
|
containerId: "ctn1",
|
||||||
|
containerName: "my-container",
|
||||||
|
onClose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("LogModal.svelte", () => {
|
||||||
|
it("renders the container name in the header", () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
expect(screen.getByText("my-container")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty-state placeholder before any log arrives", () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a WebSocket with the correct URL", () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
expect(MockWebSocket.last).not.toBeNull();
|
||||||
|
expect(MockWebSocket.last!.url).toContain("/api/v1/agents/agent1/containers/ctn1/logs");
|
||||||
|
expect(MockWebSocket.last!.url).toContain("token=test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays incoming log lines", async () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
MockWebSocket.last!.emit({ stream: "stdout", line: "Server started" });
|
||||||
|
|
||||||
|
// Give Svelte a tick to update the DOM
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Server started")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips ANSI escape codes from log lines", async () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
MockWebSocket.last!.emit({ stream: "stdout", line: "\x1b[32mGreen text\x1b[0m" });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.getByText("Green text")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears lines when the clear button is clicked", async () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
MockWebSocket.last!.emit({ stream: "stdout", line: "some log" });
|
||||||
|
|
||||||
|
await vi.waitFor(() => screen.getByText("some log"));
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByTitle("Effacer"));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(screen.queryByText("some log")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when the close button is clicked", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(LogModal, { props: { ...defaultProps, onClose } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByTitle(/fermer/i));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when the Escape key is pressed", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(LogModal, { props: { ...defaultProps, onClose } });
|
||||||
|
|
||||||
|
await fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the WebSocket when the component is unmounted", async () => {
|
||||||
|
const { unmount } = render(LogModal, { props: defaultProps });
|
||||||
|
const ws = MockWebSocket.last!;
|
||||||
|
expect(ws.closed).toBe(false);
|
||||||
|
unmount();
|
||||||
|
expect(ws.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores messages with an empty line field", async () => {
|
||||||
|
render(LogModal, { props: defaultProps });
|
||||||
|
MockWebSocket.last!.emit({ stream: "stdout", line: "" });
|
||||||
|
|
||||||
|
// The placeholder should still be visible (no lines added)
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
296
web/src/tests/api.test.ts
Normal file
296
web/src/tests/api.test.ts
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchAgents,
|
||||||
|
fetchContainers,
|
||||||
|
containerAction,
|
||||||
|
updateAgent,
|
||||||
|
connectLogs,
|
||||||
|
connectEvents,
|
||||||
|
} from "../lib/api";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mockFetch(status: number, body: unknown, ok = status >= 200 && status < 300) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal WebSocket mock */
|
||||||
|
class MockWebSocket {
|
||||||
|
static instances: MockWebSocket[] = [];
|
||||||
|
url: string;
|
||||||
|
onmessage: ((e: { data: string }) => void) | null = null;
|
||||||
|
onclose: (() => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
MockWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: simulate incoming message */
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup / teardown ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
MockWebSocket.instances = [];
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
// Default location stubs (jsdom provides location but let's make sure)
|
||||||
|
Object.defineProperty(globalThis, "location", {
|
||||||
|
value: {
|
||||||
|
protocol: "http:",
|
||||||
|
host: "localhost:5173",
|
||||||
|
href: "",
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── fetchAgents ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("fetchAgents", () => {
|
||||||
|
it("returns agents array on success", async () => {
|
||||||
|
const agents = [{ id: "a1", hostname: "host1", alias: "", ip_address: "1.2.3.4", arch: "amd64", os: "linux", online: true, last_seen_at: "2024-01-01" }];
|
||||||
|
vi.stubGlobal("fetch", mockFetch(200, agents));
|
||||||
|
|
||||||
|
const result = await fetchAgents();
|
||||||
|
expect(result).toEqual(agents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
vi.stubGlobal("fetch", mockFetch(500, {}, false));
|
||||||
|
await expect(fetchAgents()).rejects.toThrow("agents: 500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Authorization header when token is present", async () => {
|
||||||
|
localStorage.setItem("containarr_token", "my-token");
|
||||||
|
const fetchMock = mockFetch(200, []);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await fetchAgents();
|
||||||
|
|
||||||
|
const [, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(opts.headers?.Authorization).toBe("Bearer my-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login and clears token on 401", async () => {
|
||||||
|
localStorage.setItem("containarr_token", "expired");
|
||||||
|
vi.stubGlobal("fetch", mockFetch(401, {}, false));
|
||||||
|
|
||||||
|
await expect(fetchAgents()).rejects.toThrow("Session expirée");
|
||||||
|
expect(localStorage.getItem("containarr_token")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── updateAgent ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("updateAgent", () => {
|
||||||
|
it("sends PATCH with alias and returns updated agent", async () => {
|
||||||
|
const updated = { id: "a1", hostname: "host1", alias: "new-alias", ip_address: "1.2.3.4", arch: "amd64", os: "linux", online: true, last_seen_at: "2024-01-01" };
|
||||||
|
const fetchMock = mockFetch(200, updated);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await updateAgent("a1", "new-alias");
|
||||||
|
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("/api/v1/agents/a1");
|
||||||
|
expect(opts.method).toBe("PATCH");
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ alias: "new-alias" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on error", async () => {
|
||||||
|
vi.stubGlobal("fetch", mockFetch(404, {}, false));
|
||||||
|
await expect(updateAgent("missing", "alias")).rejects.toThrow("update agent: 404");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── fetchContainers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("fetchContainers", () => {
|
||||||
|
it("returns container entries on success", async () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
agent_id: "a1",
|
||||||
|
hostname: "host1",
|
||||||
|
alias: "",
|
||||||
|
ip_address: "1.2.3.4",
|
||||||
|
container: {
|
||||||
|
id: "c1",
|
||||||
|
name: "my-app",
|
||||||
|
image: "nginx:latest",
|
||||||
|
status: "running",
|
||||||
|
state: "running",
|
||||||
|
ports: [],
|
||||||
|
created_at: 0,
|
||||||
|
labels: {},
|
||||||
|
compose_project: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.stubGlobal("fetch", mockFetch(200, entries));
|
||||||
|
|
||||||
|
const result = await fetchContainers();
|
||||||
|
expect(result).toEqual(entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok status", async () => {
|
||||||
|
vi.stubGlobal("fetch", mockFetch(503, {}, false));
|
||||||
|
await expect(fetchContainers()).rejects.toThrow("containers: 503");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an AbortSignal to fetch", async () => {
|
||||||
|
const fetchMock = mockFetch(200, []);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await fetchContainers();
|
||||||
|
|
||||||
|
const [, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(opts.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── containerAction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("containerAction", () => {
|
||||||
|
it.each(["start", "stop", "restart", "remove"] as const)(
|
||||||
|
"sends %s action and returns command_id",
|
||||||
|
async (action) => {
|
||||||
|
const fetchMock = mockFetch(200, { command_id: "cmd-42" });
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await containerAction("agent1", "container1", action);
|
||||||
|
expect(result).toEqual({ command_id: "cmd-42" });
|
||||||
|
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("/api/v1/agents/agent1/containers/container1/action");
|
||||||
|
expect(opts.method).toBe("POST");
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ action });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("throws on error", async () => {
|
||||||
|
vi.stubGlobal("fetch", mockFetch(500, {}, false));
|
||||||
|
await expect(containerAction("a", "c", "start")).rejects.toThrow("action failed: 500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── connectLogs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("connectLogs", () => {
|
||||||
|
it("creates a WebSocket with the correct URL", () => {
|
||||||
|
localStorage.setItem("containarr_token", "tok123");
|
||||||
|
connectLogs("agent1", "container1", () => {});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
expect(ws.url).toContain("/api/v1/agents/agent1/containers/container1/logs");
|
||||||
|
expect(ws.url).toContain("token=tok123");
|
||||||
|
expect(ws.url).toContain("tail=200");
|
||||||
|
expect(ws.url).toContain("follow=true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onLine with parsed message", () => {
|
||||||
|
const onLine = vi.fn();
|
||||||
|
connectLogs("a", "c", onLine);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
ws.emit({ stream: "stdout", line: "hello world" });
|
||||||
|
|
||||||
|
expect(onLine).toHaveBeenCalledOnce();
|
||||||
|
expect(onLine).toHaveBeenCalledWith({ stream: "stdout", line: "hello world" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a close function that closes the WebSocket", () => {
|
||||||
|
const close = connectLogs("a", "c", () => {});
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
expect(ws.closed).toBe(false);
|
||||||
|
close();
|
||||||
|
expect(ws.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently ignores invalid JSON messages", () => {
|
||||||
|
const onLine = vi.fn();
|
||||||
|
connectLogs("a", "c", onLine);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
ws.onmessage?.({ data: "not-json{{{{" });
|
||||||
|
|
||||||
|
expect(onLine).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses wss when protocol is https", () => {
|
||||||
|
Object.defineProperty(globalThis, "location", {
|
||||||
|
value: { protocol: "https:", host: "myapp.io" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
connectLogs("a", "c", () => {});
|
||||||
|
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects custom tail and follow parameters", () => {
|
||||||
|
connectLogs("a", "c", () => {}, 50, false);
|
||||||
|
const url = MockWebSocket.instances[0].url;
|
||||||
|
expect(url).toContain("tail=50");
|
||||||
|
expect(url).toContain("follow=false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── connectEvents ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("connectEvents", () => {
|
||||||
|
it("creates a WebSocket pointing to /api/v1/events", () => {
|
||||||
|
localStorage.setItem("containarr_token", "evtTok");
|
||||||
|
connectEvents(() => {});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
expect(ws.url).toContain("/api/v1/events");
|
||||||
|
expect(ws.url).toContain("token=evtTok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onEvent with parsed message", () => {
|
||||||
|
const onEvent = vi.fn();
|
||||||
|
connectEvents(onEvent);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
ws.emit({ type: "containers.updated", payload: { count: 3 } });
|
||||||
|
|
||||||
|
expect(onEvent).toHaveBeenCalledOnce();
|
||||||
|
expect(onEvent).toHaveBeenCalledWith({ type: "containers.updated", payload: { count: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a close function that closes the WebSocket", () => {
|
||||||
|
const close = connectEvents(() => {});
|
||||||
|
const ws = MockWebSocket.instances[0];
|
||||||
|
close();
|
||||||
|
expect(ws.closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed JSON", () => {
|
||||||
|
const onEvent = vi.fn();
|
||||||
|
connectEvents(onEvent);
|
||||||
|
MockWebSocket.instances[0].onmessage?.({ data: "{{bad}}" });
|
||||||
|
expect(onEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
120
web/src/tests/auth.test.ts
Normal file
120
web/src/tests/auth.test.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { getToken, setToken, clearToken, isLoggedIn, login } from "../lib/auth";
|
||||||
|
|
||||||
|
// localStorage is available via jsdom
|
||||||
|
|
||||||
|
describe("auth.ts", () => {
|
||||||
|
const KEY = "containarr_token";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- getToken ---
|
||||||
|
describe("getToken", () => {
|
||||||
|
it("returns null when no token is stored", () => {
|
||||||
|
expect(getToken()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the stored token", () => {
|
||||||
|
localStorage.setItem(KEY, "my-token");
|
||||||
|
expect(getToken()).toBe("my-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- setToken ---
|
||||||
|
describe("setToken", () => {
|
||||||
|
it("stores the token in localStorage", () => {
|
||||||
|
setToken("abc123");
|
||||||
|
expect(localStorage.getItem(KEY)).toBe("abc123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- clearToken ---
|
||||||
|
describe("clearToken", () => {
|
||||||
|
it("removes the token from localStorage", () => {
|
||||||
|
localStorage.setItem(KEY, "to-remove");
|
||||||
|
clearToken();
|
||||||
|
expect(localStorage.getItem(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if no token is stored", () => {
|
||||||
|
expect(() => clearToken()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- isLoggedIn ---
|
||||||
|
describe("isLoggedIn", () => {
|
||||||
|
it("returns false when no token is stored", () => {
|
||||||
|
expect(isLoggedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a malformed token", () => {
|
||||||
|
localStorage.setItem(KEY, "not.a.jwt");
|
||||||
|
expect(isLoggedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeJWT(payload: Record<string, unknown>): string {
|
||||||
|
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const body = btoa(JSON.stringify(payload));
|
||||||
|
return `${header}.${body}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns true for a non-expired token", () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 3600; // +1h
|
||||||
|
const token = makeJWT({ sub: "user", exp });
|
||||||
|
localStorage.setItem(KEY, token);
|
||||||
|
expect(isLoggedIn()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an expired token", () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) - 3600; // -1h
|
||||||
|
const token = makeJWT({ sub: "user", exp });
|
||||||
|
localStorage.setItem(KEY, token);
|
||||||
|
expect(isLoggedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- login ---
|
||||||
|
describe("login", () => {
|
||||||
|
it("stores the token on success", async () => {
|
||||||
|
const fakeToken = "fresh-jwt-token";
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ token: fakeToken }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await login("admin", "password");
|
||||||
|
expect(localStorage.getItem(KEY)).toBe(fakeToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: false })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(login("admin", "wrong")).rejects.toThrow("Identifiants invalides");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the correct endpoint with correct body", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ token: "tok" }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await login("myuser", "mypass");
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe("/api/v1/auth/login");
|
||||||
|
expect(opts.method).toBe("POST");
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ username: "myuser", password: "mypass" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
web/src/tests/setup.ts
Normal file
1
web/src/tests/setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
54
web/src/tests/stripAnsi.test.ts
Normal file
54
web/src/tests/stripAnsi.test.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the stripAnsi logic used in LogModal.svelte.
|
||||||
|
*
|
||||||
|
* The function is defined inline in the component; we replicate it here
|
||||||
|
* identically so it can be unit-tested without mounting the full component.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Replicated directly from LogModal.svelte (src/lib/LogModal.svelte)
|
||||||
|
function stripAnsi(str: string): string {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("stripAnsi", () => {
|
||||||
|
it("passes through plain text unchanged", () => {
|
||||||
|
expect(stripAnsi("hello world")).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a simple SGR reset sequence (\\x1b[0m)", () => {
|
||||||
|
expect(stripAnsi("\x1b[0mhello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips bold / colour sequences", () => {
|
||||||
|
expect(stripAnsi("\x1b[1;32mGreen bold\x1b[0m")).toBe("Green bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips cursor-move sequences (G, K, H, F)", () => {
|
||||||
|
expect(stripAnsi("\x1b[2K\x1b[1Gsome line")).toBe("some line");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips multiple sequences in a single string", () => {
|
||||||
|
const raw = "\x1b[31mERROR\x1b[0m: \x1b[1mfoo\x1b[0m";
|
||||||
|
expect(stripAnsi(raw)).toBe("ERROR: foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multi-param sequences like \\x1b[38;5;200m", () => {
|
||||||
|
expect(stripAnsi("\x1b[38;5;200mtext\x1b[0m")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not strip incomplete escape sequences (no letter terminator)", () => {
|
||||||
|
// "\x1b[123" is incomplete — no terminating letter, should stay
|
||||||
|
const partial = "\x1b[123";
|
||||||
|
expect(stripAnsi(partial)).toBe(partial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string unchanged", () => {
|
||||||
|
expect(stripAnsi("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves newlines", () => {
|
||||||
|
expect(stripAnsi("\x1b[32mline1\x1b[0m\nline2")).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
web/static/icon-192.png
Normal file
BIN
web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
web/static/icon-512.png
Normal file
BIN
web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
14
web/svelte.config.js
Normal file
14
web/svelte.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: "index.html", // SPA mode for PWA
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
46
web/tailwind.config.js
Normal file
46
web/tailwind.config.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
abyss: {
|
||||||
|
950: "#03070f",
|
||||||
|
900: "#060c18",
|
||||||
|
800: "#0a1220",
|
||||||
|
700: "#0f1b2e",
|
||||||
|
600: "#162238",
|
||||||
|
500: "#1e2f48",
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
DEFAULT: "#10b981",
|
||||||
|
bright: "#34d399",
|
||||||
|
dim: "#0a3d26",
|
||||||
|
},
|
||||||
|
signal: {
|
||||||
|
green: "#34d399",
|
||||||
|
red: "#f87171",
|
||||||
|
yellow: "#fbbf24",
|
||||||
|
cyan: "#2dd4bf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ["JetBrains Mono", "Fira Code", "ui-monospace", "monospace"],
|
||||||
|
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
"glow-green": "0 0 10px rgba(52,211,153,0.45)",
|
||||||
|
"glow-emerald": "0 0 14px rgba(16,185,129,0.4)",
|
||||||
|
card: "0 4px 24px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
"grid-faint":
|
||||||
|
"linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)",
|
||||||
|
},
|
||||||
|
backgroundSize: {
|
||||||
|
grid: "32px 32px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
13
web/tsconfig.json
Normal file
13
web/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
47
web/vite.config.ts
Normal file
47
web/vite.config.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||||
|
import { svelteTesting } from "@testing-library/svelte/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/tests/setup.ts"],
|
||||||
|
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
sveltekit(),
|
||||||
|
svelteTesting(),
|
||||||
|
SvelteKitPWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
manifest: {
|
||||||
|
name: "Containarr",
|
||||||
|
short_name: "Containarr",
|
||||||
|
description: "Multi-VM Docker container manager",
|
||||||
|
theme_color: "#0f172a",
|
||||||
|
background_color: "#0f172a",
|
||||||
|
display: "standalone",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8080",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user