feat: add first page with auth and containers list and agents

This commit is contained in:
2026-05-18 08:24:02 +02:00
parent 446087ae01
commit 3b4a841bf5
56 changed files with 16267 additions and 0 deletions

9
.env.example Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
agent/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
{
"name": "Containarr",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

100
proto/agent/v1/agent.proto Normal file
View 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
View 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
View 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
View 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
View 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=

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

33
web/package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

86
web/src/app.css Normal file
View 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
View 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
View 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
View 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
View 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);
}

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

View File

@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

351
web/src/routes/+page.svelte Normal file
View 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}

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

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

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

@ -0,0 +1 @@
import "@testing-library/jest-dom";

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

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
View 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
View 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
View 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
View 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,
},
},
},
});