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