8 Commits

Author SHA1 Message Date
46fba794f3 Merge pull request 'bugfix-and-rewrite' (#59) from bugfix-and-rewrite into dev
Reviewed-on: #59
2026-07-13 08:00:00 +00:00
0fd561726e Finish sandbox-to-capsule ID rename and clean up leftover stub files
Move client-IP parsing into a shared pkg/netutil helper used by both
auth handlers and session middleware, update remaining sb-/wrenn-sb-
references to the cl-/wrenn-cl- naming, and remove a few empty
placeholder files left over from earlier package splits. Also updates
docs and the default listen port.
2026-07-12 21:31:10 +06:00
01fd1ddc81 Harden user-facing inputs and channel webhooks against abuse
- Validate emails and display names (users, API keys, OAuth signups) against a strict allowlist so stored values can't carry HTML/script payloads
- Block notification channel webhooks from targeting private/internal/metadata addresses (SSRF), with an opt-out env var for self-hosted setups that need it
- Bound concurrent channel delivery retries so a sustained outage can't spawn unbounded goroutines
- Fail closed when the admin check can't reach the database, instead of quietly denying a legitimate admin
- Remove envd's dead auth middleware (unused, envd relies on VM isolation not per-request auth) and drop unused username plumbing from the filesystem service
2026-07-12 18:31:53 +06:00
bb7725c323 Drop legacy raw-JWT credentials file format
Host credentials file has been JSON for a while; old hosts on the
raw-JWT format will just re-register.
2026-07-12 17:24:31 +06:00
b255042496 Fix audit log misattributing failed capsule startups to system
Failed create/resume now logs as a separate "error" entry instead of
overwriting who actually created the capsule.
2026-07-12 16:58:47 +06:00
7d782daed3 Switch API error responses to structured error codes
Backend handlers now return errors through a shared apperr catalog
instead of ad-hoc strings, so every error has a stable code, a
human message, and a request ID. The frontend API client understands
this new error shape and surfaces the code/request ID to callers so
error messages (and support requests) are more useful.
2026-07-12 16:38:59 +06:00
31e77f8f6a Fix capsule showing running instead of starting on launch
The dashboard jumped a launching capsule straight to running, skipping
the starting state. The event stream was treating the request-accepted
event the same as the boot-complete event and forcing a running status.
Now only true completion events set the final status, so transient
states show correctly.
2026-07-12 08:53:34 +06:00
e58a17bf03 Make file operations run as sandbox default user, not root
File reads and writes forced username=root when talking to envd, while
process execution ran as the sandbox default user. Drop the hardcoded
user so file ops resolve the same default user as exec.
2026-07-12 07:05:41 +06:00
95 changed files with 2302 additions and 943 deletions

View File

@ -47,6 +47,10 @@ WRENN_CA_KEY=
# Channels (notification destinations)
# AES-256-GCM key for encrypting channel secrets. Generate with: openssl rand -hex 32
WRENN_ENCRYPTION_KEY=
# Allow notification channels to target private/loopback/link-local addresses.
# Off by default to prevent SSRF; set to true only on self-hosted deployments
# that deliver to internal endpoints.
WRENN_CHANNELS_ALLOW_PRIVATE=false
# OAuth
OAUTH_GITHUB_CLIENT_ID=

1
.gitignore vendored
View File

@ -33,7 +33,6 @@ go.work.sum
## AI
.claude/
e2b/
.impeccable.md
.gstack
.mcp.json

View File

@ -60,13 +60,13 @@ User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/
envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completely independent from the Go module — the only connection is the protobuf contract. It compiles to a statically linked musl binary baked into rootfs images.
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The reconciler (`internal/api/reconciler.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The host monitor (`internal/api/host_monitor.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
### Control Plane
**Internal packages:** `internal/api/`, `internal/email/`
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`, `pkg/netutil/`
**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext` + hook interfaces), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`)
@ -88,7 +88,7 @@ It can optionally implement any of these hook interfaces (the OSS server type-as
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. Accepts `[]cpextension.Extension` — each extension's `RegisterRoutes()` is called after all core routes are registered.
- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped".
- **Host monitor** (`internal/api/host_monitor.go`): safety-net reconciliation goroutine (every 5min, wired in `pkg/cpserver/run.go` via `NewHostMonitor`). Primary state sync is push-based (host agent callbacks + CP background goroutines); the monitor is the fallback for missed events, host-death detection, and transient-status resolution. For each online host it calls `ListSandboxes()` and reconciles orphaned DB entries.
- **Dashboard** (SvelteKit + Tailwind + Bits UI, built to static files in `frontend/build/`, served by Caddy as a reverse proxy)
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/*.sql``pkg/db/`. Migrations in `db/migrations/` (goose, plain SQL). `db/migrations/embed.go` exposes `migrations.FS` so the cloud repo can run OSS migrations via `go:embed`.
- **Config** (`pkg/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config.
@ -251,7 +251,7 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
## Coding Conventions
- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state
- **Naming**: Sandbox IDs `sb-` + 8 hex, API keys `wrn_` + 32 chars, Host IDs `host-` + 8 hex
- **Naming**: IDs are a type prefix + a 25-char base36-encoded UUID (`pkg/id`) — sandboxes `cl-`, users `usr-`, teams `team-`, hosts `host-`, etc. API keys are `wrn_` + 32 hex
- **Dependencies**: Use `go get` to add Go deps, never hand-edit go.mod. For envd-rs deps: edit `envd-rs/Cargo.toml`
- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually

View File

@ -1,53 +0,0 @@
use std::sync::Arc;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::auth::token::SecureToken;
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
/// Paths excluded from general token auth.
/// Format: "METHOD/path"
const AUTH_EXCLUDED: &[&str] = &[
"GET/health",
"GET/activity",
"GET/files",
"POST/files",
"POST/init",
"POST/snapshot/prepare",
];
/// Axum middleware that checks X-Access-Token header.
pub async fn auth_layer(request: Request, next: Next, access_token: Arc<SecureToken>) -> Response {
if access_token.is_set() {
let method = request.method().as_str();
let path = request.uri().path();
let key = format!("{method}{path}");
let is_excluded = AUTH_EXCLUDED.iter().any(|p| *p == key);
let header_val = request
.headers()
.get(ACCESS_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !access_token.equals(header_val) && !is_excluded {
tracing::error!("unauthorized access attempt");
return (
StatusCode::UNAUTHORIZED,
axum::Json(json!({
"code": 401,
"message": "unauthorized access, please provide a valid access token or method signing if supported"
})),
)
.into_response();
}
}
next.run(request).await
}

View File

@ -1,3 +1,2 @@
pub mod middleware;
pub mod signing;
pub mod token;

View File

@ -1,3 +1,5 @@
use subtle::ConstantTimeEq;
use crate::auth::token::SecureToken;
use crate::crypto;
use zeroize::Zeroize;
@ -67,7 +69,9 @@ pub fn validate_signing(
let expected = generate_signature(token, path, username, operation, signature_expiration)
.map_err(|e| format!("error generating signing key: {e}"))?;
if expected != sig {
// Constant-time compare: both values are fixed-length `v1_<64 hex>` digests,
// so this matches the timing-side-channel posture of SecureToken::equals.
if expected.as_bytes().ct_eq(sig.as_bytes()).unwrap_u8() != 1 {
return Err("invalid signature".into());
}

View File

@ -30,8 +30,8 @@ impl FilesystemServiceImpl {
}
}
fn resolve_path(&self, path: &str, ctx: &Context) -> Result<String, ConnectError> {
let username = extract_username(ctx).unwrap_or_else(|| self.state.defaults.user());
fn resolve_path(&self, path: &str) -> Result<String, ConnectError> {
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| {
ConnectError::new(ErrorCode::Unauthenticated, format!("invalid user: {e}"))
})?;
@ -44,20 +44,13 @@ impl FilesystemServiceImpl {
}
}
fn extract_username(ctx: &Context) -> Option<String> {
ctx.extensions.get::<AuthUser>().map(|u| u.0.clone())
}
#[derive(Clone)]
pub struct AuthUser(pub String);
impl Filesystem for FilesystemServiceImpl {
async fn stat(
&self,
ctx: Context,
request: buffa::view::OwnedView<StatRequestView<'static>>,
) -> Result<(StatResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
let entry = build_entry_info(&path)?;
Ok((
StatResponse {
@ -73,7 +66,7 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<MakeDirRequestView<'static>>,
) -> Result<(MakeDirResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
match std::fs::metadata(&path) {
Ok(meta) => {
@ -97,7 +90,7 @@ impl Filesystem for FilesystemServiceImpl {
}
}
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
ensure_dirs(&path, user.uid, user.gid)
@ -118,10 +111,10 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<MoveRequestView<'static>>,
) -> Result<(MoveResponse, Context), ConnectError> {
let source = self.resolve_path(request.source, &ctx)?;
let destination = self.resolve_path(request.destination, &ctx)?;
let source = self.resolve_path(request.source)?;
let destination = self.resolve_path(request.destination)?;
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
let username = self.state.defaults.user();
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
if let Some(parent) = Path::new(&destination).parent() {
@ -157,7 +150,7 @@ impl Filesystem for FilesystemServiceImpl {
depth = 1;
}
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
// The recursive walk stats every entry (plus uid/gid lookups) — on a
// large tree that is seconds of blocking syscalls, so it runs on the
@ -200,7 +193,7 @@ impl Filesystem for FilesystemServiceImpl {
ctx: Context,
request: buffa::view::OwnedView<RemoveRequestView<'static>>,
) -> Result<(RemoveResponse, Context), ConnectError> {
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
// remove_dir_all recurses through the whole tree — blocking pool, not
// a runtime worker thread.
@ -250,7 +243,7 @@ impl Filesystem for FilesystemServiceImpl {
) -> Result<(CreateWatcherResponse, Context), ConnectError> {
use notify::{RecursiveMode, Watcher};
let path = self.resolve_path(request.path, &ctx)?;
let path = self.resolve_path(request.path)?;
let recursive = request.recursive;
if let Ok(true) = crate::rpc::entry::is_network_mount(&path) {

View File

@ -1,3 +1,5 @@
import { apiFailure } from '$lib/api/client';
export type AuthResponse = {
user_id: string;
team_id: string;
@ -38,8 +40,8 @@ async function authFetch<T = AuthResponse>(url: string, body: Record<string, str
const data = await res.json();
if (!res.ok) {
const message = data?.error?.message ?? 'Something went wrong';
return { ok: false, error: message };
const failure = apiFailure(data);
return { ok: false, error: failure.error };
}
return { ok: true, data: data as T };

View File

@ -1,7 +1,42 @@
import { goto } from '$app/navigation';
import { auth, readCSRFToken } from '$lib/auth.svelte';
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
export type ApiFailure = {
ok: false;
/** Human-readable message, ready to show in a toast. */
error: string;
/** Machine-readable error code, e.g. "sandbox_not_running". */
code?: string;
/** Server request ID — quote it when reporting issues. */
requestId?: string;
/** Whether retrying the same request may succeed. */
retryable?: boolean;
details?: Record<string, unknown>;
};
export type ApiResult<T> = { ok: true; data: T } | ApiFailure;
/**
* Normalizes the API error envelope
* `{"error":{code,message,request_id,retryable,details}}` into an ApiFailure.
* Internal errors get the request ID appended so users can quote it.
*/
export function apiFailure(data: unknown, fallback = 'Something went wrong'): ApiFailure {
const e = (data as { error?: Record<string, unknown> } | null)?.error;
let message = typeof e?.message === 'string' && e.message ? e.message : fallback;
const requestId = typeof e?.request_id === 'string' ? e.request_id : undefined;
if (requestId && e?.code === 'internal_error') {
message += ` (ref: ${requestId})`;
}
return {
ok: false,
error: message,
code: typeof e?.code === 'string' ? e.code : undefined,
requestId,
retryable: e?.retryable === true,
details: (e?.details as Record<string, unknown> | undefined) ?? undefined
};
}
async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
if (res.status === 204 || res.status === 202) {
@ -19,8 +54,17 @@ async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
}
}
const data = await res.json();
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
let data: unknown;
try {
data = await res.json();
} catch {
// Non-JSON body (e.g. a 502/504 from the proxy when the control plane
// is unreachable). Surface the status rather than masking it as a
// connection failure in the caller's catch.
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
return { ok: true, data: undefined as T };
}
if (!res.ok) return apiFailure(data);
return { ok: true, data: data as T };
}

View File

@ -1,5 +1,5 @@
import { readCSRFToken } from '$lib/auth.svelte';
import { apiFetch, type ApiResult } from '$lib/api/client';
import { apiFailure, apiFetch, type ApiResult } from '$lib/api/client';
export type FileEntry = {
name: string;
@ -78,7 +78,8 @@ export async function readFile(
if (!res.ok) {
try {
const data = await res.json();
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
const failure = apiFailure(data, 'Failed to read file');
return { ok: false, error: failure.error };
} catch {
return { ok: false, error: `HTTP ${res.status}` };
}
@ -114,7 +115,15 @@ export async function downloadFile(
signal,
});
if (!res.ok) throw new Error('Download failed');
if (!res.ok) {
let data: unknown;
try {
data = await res.json();
} catch {
throw new Error(`Download failed (HTTP ${res.status})`);
}
throw new Error(apiFailure(data, 'Download failed').error);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);

View File

@ -1,4 +1,4 @@
import { apiFetch } from './client';
import { apiFetch, type ApiFailure } from './client';
export type Host = {
id: string;
@ -55,11 +55,11 @@ export async function createHost(
export async function deleteHost(
id: string,
force = false
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
): Promise<{ ok: true } | ApiFailure> {
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
const res = await apiFetch<void>('DELETE', url);
if (!res.ok) {
return res as { ok: false; error: string };
return res;
}
return { ok: true };
}

View File

@ -39,7 +39,7 @@
};
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
sandbox: ['create', 'pause', 'resume', 'destroy'],
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
snapshot: ['create', 'delete'],
template: ['delete'],
build: ['create', 'cancel'],
@ -56,6 +56,7 @@
pause: 'Paused',
resume: 'Resumed',
destroy: 'Destroyed',
error: 'Error',
delete: 'Deleted',
rename: 'Renamed',
revoke: 'Revoked',
@ -207,6 +208,9 @@
const DELETED_BADGE = '\x00DELETED\x00';
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
// The interpolated fields (user/API-key/team names, emails) are constrained
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
// the only markup here is the trusted DELETED_BADGE swap.
function renderDeleted(text: string): string {
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
}
@ -219,6 +223,10 @@
case 'sandbox:pause': return `${actor} paused a capsule`;
case 'sandbox:resume': return `${actor} resumed a capsule`;
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
case 'sandbox:error':
if (meta.phase === 'resume') return 'A capsule failed to resume';
if (meta.phase === 'create') return 'A capsule failed to start';
return 'A capsule encountered an error';
case 'snapshot:create': return `${actor} created a snapshot`;
case 'snapshot:delete': return `${actor} deleted a snapshot`;
case 'template:delete': return `${actor} deleted template "${log.resource_id}"`;

View File

@ -37,7 +37,7 @@
};
const ACTIONS_BY_RESOURCE: Record<string, string[]> = {
sandbox: ['create', 'pause', 'resume', 'destroy'],
sandbox: ['create', 'pause', 'resume', 'destroy', 'error'],
snapshot: ['create', 'delete'],
team: ['rename'],
api_key: ['create', 'revoke'],
@ -50,6 +50,7 @@
pause: 'Paused',
resume: 'Resumed',
destroy: 'Destroyed',
error: 'Error',
delete: 'Deleted',
rename: 'Renamed',
revoke: 'Revoked',
@ -195,6 +196,9 @@
const DELETED_BADGE = '\x00DELETED\x00';
const deletedBadgeHtml = '<span class="deleted-user-badge">deleted-user</span>';
// The interpolated fields (user/API-key/team names, emails) are constrained
// on the backend to an HTML-safe charset — no < > & " ' can be stored — so
// the only markup here is the trusted DELETED_BADGE swap.
function renderDeleted(text: string): string {
return text.replaceAll(DELETED_BADGE, deletedBadgeHtml);
}
@ -207,6 +211,10 @@
case 'sandbox:pause': return `${actor} paused a capsule`;
case 'sandbox:resume': return `${actor} resumed a capsule`;
case 'sandbox:destroy': return `${actor} destroyed a capsule`;
case 'sandbox:error':
if (meta.phase === 'resume') return 'A capsule failed to resume';
if (meta.phase === 'create') return 'A capsule failed to start';
return 'A capsule encountered an error';
case 'snapshot:create': return `${actor} created a template`;
case 'snapshot:delete': return `${actor} deleted a template`;
case 'team:rename': return `${actor} renamed the team from "${meta.old_name}" to "${meta.new_name}"`;

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFailure } from '$lib/api/client';
type EndpointStatus = 'loading' | 'available' | 'not_available' | 'error';
let status = $state<EndpointStatus>('loading');
@ -16,7 +17,7 @@
status = 'error';
try {
const data = await res.json();
errorMsg = data?.error?.message ?? `Server returned ${res.status}`;
errorMsg = apiFailure(data, `Server returned ${res.status}`).error;
} catch {
errorMsg = `Server returned ${res.status}`;
}

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -39,17 +40,17 @@ func requireRunningSandbox(w http.ResponseWriter, r *http.Request, queries *db.Q
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
sb, err := queries.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")")
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", sb.Status))
return db.Sandbox{}, pgtype.UUID{}, "", false
}
@ -71,7 +72,7 @@ func upgradeAndAuthenticate(w http.ResponseWriter, r *http.Request) (*websocket.
func upgradeAndAuthenticateWith(w http.ResponseWriter, r *http.Request, up *websocket.Upgrader) (*websocket.Conn, auth.AuthContext, error) {
ac, hasAuth := auth.FromContext(r.Context())
if !hasAuth {
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
writeErr(w, r, apperr.AuthSessionRequired.New())
return nil, auth.AuthContext{}, fmt.Errorf("unauthenticated")
}
conn, err := up.Upgrade(w, r, nil)

View File

@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -143,25 +144,26 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
// Validate port.
portNum, err := strconv.Atoi(port)
if err != nil || portNum < 1 || portNum > 65535 {
http.Error(w, "invalid port", http.StatusBadRequest)
writeErr(w, r, apperr.InvalidRequest.Msg("Invalid port."))
return
}
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
agentURL, err := h.proxyTarget(r.Context(), sandboxID)
if err != nil {
var notRunning errProxySandboxNotRunning
switch {
case errors.Is(err, errProxySandboxNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
case errors.As(err, new(errProxySandboxNotRunning)):
http.Error(w, err.Error(), http.StatusConflict)
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
case errors.As(err, &notRunning):
writeErr(w, r, apperr.SandboxNotRunning.New().With("status", notRunning.status))
default:
http.Error(w, err.Error(), http.StatusServiceUnavailable)
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
}
return
}
@ -196,7 +198,7 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
"error", err,
)
h.evictProxyCache(sandboxID)
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
},
}
proxy.ServeHTTP(w, r)

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -29,7 +30,7 @@ func newAdminCapsuleHandler(svc *service.SandboxService, db *db.Queries, pool *l
func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -48,8 +49,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if sb.ID.Valid {
h.audit.LogSandboxDestroySystem(r.Context(), id.PlatformTeamID, sb.ID, "cleanup_after_create_error", nil)
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -60,7 +60,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) {
func (h *adminCapsuleHandler) List(w http.ResponseWriter, r *http.Request) {
sandboxes, err := h.svc.List(r.Context(), id.PlatformTeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -78,13 +78,13 @@ func (h *adminCapsuleHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Get(r.Context(), sandboxID, id.PlatformTeamID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -98,7 +98,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
@ -106,8 +106,7 @@ func (h *adminCapsuleHandler) Destroy(w http.ResponseWriter, r *http.Request) {
err = h.svc.Destroy(r.Context(), sandboxID, id.PlatformTeamID)
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -125,22 +124,21 @@ func (h *adminCapsuleHandler) Snapshot(w http.ResponseWriter, r *http.Request) {
sandboxIDStr := chi.URLParam(r, "id")
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
var req adminSnapshotRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
}
sb, name, err := h.svc.CreateSnapshot(r.Context(), sandboxID, id.PlatformTeamID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
ac := auth.MustFromContext(r.Context())

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -81,13 +82,13 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createAPIKeyRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create API key")
writeErr(w, r, err)
return
}
@ -104,7 +105,7 @@ func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
keys, err := h.svc.ListWithCreator(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
writeErr(w, r, err)
return
}
@ -123,12 +124,12 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
keyID, err := id.ParseAPIKeyID(keyIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid API key ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid API key ID."))
return
}
if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
writeErr(w, r, err)
return
}

View File

@ -1,6 +1,7 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
@ -8,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
@ -48,10 +50,14 @@ func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID,
if s := r.URL.Query().Get("limit"); s != "" {
n, parseErr := strconv.Atoi(s)
if parseErr != nil || n < 1 {
if parseErr != nil {
err = parseErr
return
}
if n < 1 {
err = fmt.Errorf("limit must be a positive integer, got %d", n)
return
}
limit = n
}
@ -107,7 +113,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
return
}
@ -121,7 +127,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -134,7 +140,7 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
before, beforeID, limit, err := parseAuditParams(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid query parameters."))
return
}
@ -148,7 +154,7 @@ func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) {
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -19,11 +19,14 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
const (
@ -186,7 +189,7 @@ func (h *authHandler) issueSession(
email, name, role string,
isAdmin bool,
) error {
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return err
}
@ -194,18 +197,6 @@ func (h *authHandler) issueSession(
return nil
}
// clientIP returns the request's apparent client IP, honoring
// X-Forwarded-For when behind a reverse proxy.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
type signupResponse struct {
Message string `json:"message"`
}
@ -214,22 +205,22 @@ type signupResponse struct {
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Name = strings.TrimSpace(req.Name)
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
if err := validate.Email(req.Email); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "A valid email address is required.").With("field", "email"))
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "password"))
return
}
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
if err := validate.DisplayName(req.Name); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
return
}
@ -243,28 +234,27 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
case "inactive":
// Unactivated user — allow re-signup after cooldown.
if time.Since(existing.CreatedAt.Time) < signupCooldown {
writeError(w, http.StatusConflict, "signup_cooldown",
"an activation email was recently sent to this address — please check your inbox or try again later")
writeErr(w, r, apperr.AuthSignupCooldown.New())
return
}
// Cooldown passed — delete the old row and proceed with fresh signup.
if err := h.db.HardDeleteUser(ctx, existing.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to clean up previous signup")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
default:
// active, disabled, deleted — email is taken.
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
writeErr(w, r, apperr.AuthEmailTaken.New())
return
}
} else if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -278,10 +268,10 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
writeErr(w, r, apperr.AuthEmailTaken.Wrap(err))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to create user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -292,7 +282,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(userID), activationTTL).Err(); err != nil {
slog.Error("signup: failed to store activation token in redis", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create activation token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -319,12 +309,12 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
var req activateRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
@ -334,28 +324,28 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
writeError(w, http.StatusBadRequest, "invalid_token", "activation link is invalid or has expired")
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
user, err := h.db.GetUserByID(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if user.Status != "inactive" {
writeError(w, http.StatusBadRequest, "already_activated", "this account has already been activated")
writeErr(w, r, apperr.AuthAlreadyActivated.New())
return
}
@ -365,7 +355,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
Status: "active",
}); err != nil {
slog.Error("activate: failed to set user status", "user_id", id.FormatUserID(userID), "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to activate user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -373,7 +363,7 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, userID, user.Name)
if err != nil {
slog.Error("activate: failed to create default team", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to set up account")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -381,12 +371,12 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
// Fire OnSignup before issuing a session — billing must succeed first.
if err := fireOnSignup(ctx, h.authHooks, userID, team.ID, user.Email); err != nil {
slog.Error("activate: OnSignup hook failed", "user_id", id.FormatUserID(userID), "error", err)
writeError(w, http.StatusInternalServerError, "signup_hook_failed", "failed to finalize account setup")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := h.issueSession(w, r, userID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
slog.Error("activate: failed to issue session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
fireOnLogin(ctx, h.authHooks, userID)
@ -405,13 +395,13 @@ func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required")
writeErr(w, r, apperr.ValidationFailed.Msg("Email and password are required."))
return
}
@ -421,21 +411,21 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("login failed: unknown email", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !user.PasswordHash.Valid {
slog.Warn("login failed: no password set", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil {
slog.Warn("login failed: wrong password", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
@ -444,18 +434,18 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
// OK — proceed.
case "inactive":
slog.Warn("login failed: account not activated", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusForbidden, "account_not_activated", "please check your email and activate your account before signing in")
writeErr(w, r, apperr.AuthAccountNotActivated.New())
return
case "disabled":
slog.Warn("login failed: account disabled", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusForbidden, "account_disabled", "your account has been deactivated — contact your administrator to regain access")
writeErr(w, r, apperr.AuthAccountDisabled.New())
return
case "deleted":
slog.Warn("login failed: account deleted", "email", req.Email, "ip", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
default:
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
writeErr(w, r, apperr.AuthInvalidCredentials.New())
return
}
@ -463,14 +453,14 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, user.ID, user.Name)
if err != nil {
slog.Error("login: failed to ensure default team", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
isAdmin := user.IsAdmin || isFirstUser
if err := h.issueSession(w, r, user.ID, team.ID, user.Email, user.Name, role, isAdmin); err != nil {
slog.Error("login: failed to issue session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to create session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
fireOnLogin(ctx, h.authHooks, user.ID)
@ -494,17 +484,17 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
var req switchTeamRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.TeamID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "team_id is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The team_id field is required.").With("field", "team_id"))
return
}
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "The team_id field is invalid.").With("field", "team_id"))
return
}
@ -514,14 +504,14 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
team, err := h.db.GetTeam(ctx, teamID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "team not found")
writeErr(w, r, apperr.TeamNotFound.Wrap(err))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if team.DeletedAt.Valid {
writeError(w, http.StatusNotFound, "not_found", "team not found")
writeErr(w, r, apperr.TeamNotFound.New())
return
}
@ -532,26 +522,26 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "forbidden", "not a member of this team")
writeErr(w, r, apperr.Forbidden.WrapMsg(err, "You are not a member of this team."))
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Fetch current name from DB — JWT name is not trusted here (may be stale or empty for old tokens).
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Rotate the SID so any leaked old cookie loses access at the moment of
// privilege change.
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), clientIP(r))
newSess, err := h.sessions.Rotate(ctx, ac.SessionID, ac.UserID, teamID, user.Email, user.Name, membership.Role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
slog.Error("switch team: failed to rotate session", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to switch team")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
setSessionCookies(w, newSess.RawSID, newSess.CSRFToken, isSecure(r))
@ -582,7 +572,7 @@ func (h *authHandler) LogoutAll(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
if err := h.sessions.RevokeAllForUser(r.Context(), ac.UserID); err != nil {
slog.Error("logout-all: revoke failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
clearSessionCookies(w, isSecure(r))

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"git.omukk.dev/wrenn/wrenn/internal/recipe"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
@ -39,13 +40,13 @@ func (h *buildStreamHandler) Stream(w http.ResponseWriter, r *http.Request) {
buildIDStr := chi.URLParam(r, "id")
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
build, err := h.db.GetTemplateBuild(r.Context(), buildID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "build not found")
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
return
}

View File

@ -2,7 +2,6 @@ package api
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
@ -11,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -120,18 +120,18 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(ct, "multipart/") {
// 100 MB max for multipart (archive + JSON config).
if err := r.ParseMultipartForm(100 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart form")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to parse the multipart form."))
return
}
// Parse JSON config from "config" field.
configStr := r.FormValue("config")
if configStr == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "multipart form requires a 'config' JSON field")
writeErr(w, r, apperr.ValidationFailed.Msg("The config field is required in the multipart form.").With("field", "config"))
return
}
if err := json.Unmarshal([]byte(configStr), &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid config JSON in multipart form")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid config JSON in the multipart form."))
return
}
@ -143,32 +143,32 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
lr := io.LimitReader(file, maxArchiveSize+1)
archive, err = io.ReadAll(lr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to read archive file")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Failed to read the archive file."))
return
}
if int64(len(archive)) > maxArchiveSize {
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request", "archive exceeds 100 MB limit")
writeErr(w, r, apperr.PayloadTooLarge.Msg("The archive exceeds the 100 MB limit."))
return
}
archiveName = header.Filename
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The name field is required.").With("field", "name"))
return
}
if err := validate.SafeName(req.Name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Invalid template name.").With("field", "name"))
return
}
if len(req.Recipe) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "recipe must contain at least one command")
writeErr(w, r, apperr.ValidationFailed.Msg("The recipe must contain at least one command.").With("field", "recipe"))
return
}
@ -186,7 +186,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
})
if err != nil {
slog.Error("failed to create build", "error", err)
writeError(w, http.StatusInternalServerError, "build_error", "failed to create build")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -199,7 +199,7 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) List(w http.ResponseWriter, r *http.Request) {
builds, err := h.svc.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list builds")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -217,13 +217,13 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
build, err := h.svc.Get(r.Context(), buildID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "build not found")
writeErr(w, r, apperr.BuildNotFound.Wrap(err))
return
}
@ -234,7 +234,7 @@ func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.db.ListTemplates(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -275,31 +275,30 @@ func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
return
}
ctx := r.Context()
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found")
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
writeErr(w, r, apperr.TemplateProtected.New())
return
}
// Remove the files from every host before dropping the DB record, so a
// failure leaves the template intact and retryable rather than orphaned.
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
writeError(w, http.StatusConflict, "delete_failed",
"could not remove template files from all hosts: "+err.Error())
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove template files from all hosts. Try again when all hosts are online."))
return
}
if err := h.db.DeleteTemplate(ctx, tmpl.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -314,12 +313,14 @@ func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
buildID, err := id.ParseBuildID(buildIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid build ID."))
return
}
if err := h.svc.Cancel(r.Context(), buildID); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
// Cancel returns typed apperr errors (BuildNotFound / Conflict); pass
// them through so not-found stays 404 rather than collapsing to 409.
writeErr(w, r, err)
return
}

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/channels"
@ -79,7 +80,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -91,8 +92,7 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
Events: req.Events,
})
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -112,7 +112,7 @@ func (h *channelHandler) List(w http.ResponseWriter, r *http.Request) {
chs, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list channels")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -131,16 +131,16 @@ func (h *channelHandler) Get(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
ch, err := h.svc.Get(r.Context(), channelID, ac.TeamID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "channel not found")
writeErr(w, r, apperr.NotFound.WrapMsg(err, "Channel not found."))
} else {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get channel")
writeErr(w, r, apperr.Internal.Wrap(err))
}
return
}
@ -155,20 +155,19 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
var req updateChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ch, err := h.svc.Update(r.Context(), channelID, ac.TeamID, req.Name, req.Events)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -180,13 +179,12 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
func (h *channelHandler) Test(w http.ResponseWriter, r *http.Request) {
var req testChannelRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.Test(r.Context(), req.Provider, req.Config); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -200,20 +198,19 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
var req rotateConfigRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ch, err := h.svc.RotateConfig(r.Context(), channelID, ac.TeamID, req.Config)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -228,12 +225,12 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
channelID, err := id.ParseChannelID(channelIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid channel ID."))
return
}
if err := h.svc.Delete(r.Context(), channelID, ac.TeamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete channel")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -65,18 +66,18 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
var req execRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Cmd == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "cmd is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The cmd field is required.").With("field", "cmd"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -96,8 +97,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
Cwd: req.Cwd,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -123,8 +123,7 @@ func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) {
Cwd: req.Cwd,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -53,7 +54,7 @@ func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -8,6 +8,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -42,35 +43,35 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "file exceeds 100 MB limit")
writeErr(w, r, apperr.PayloadTooLarge.Msg("File exceeds the 100 MB limit."))
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data."))
return
}
filePath := r.FormValue("path")
if filePath == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -79,8 +80,7 @@ func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) {
Path: filePath,
Content: content,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -104,18 +104,18 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
var req readFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -124,8 +124,7 @@ func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) {
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -40,7 +41,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
contentType := r.Header.Get("Content-Type")
_, params, err := mime.ParseMediaType(contentType)
if err != nil || params["boundary"] == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data with boundary")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Expected multipart/form-data with a boundary."))
return
}
@ -56,7 +57,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
break
}
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Malformed multipart request body."))
return
}
switch part.FormName() {
@ -72,18 +73,18 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
}
if filePath == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
if filePart == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "file field is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The file field is required.").With("field", "file"))
return
}
defer filePart.Close()
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -105,7 +106,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
},
},
}); err != nil {
writeError(w, http.StatusBadGateway, "agent_error", "failed to send file metadata")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -119,7 +120,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
if sendErr := stream.Send(&pb.WriteFileStreamRequest{
Content: &pb.WriteFileStreamRequest_Chunk{Chunk: chunk},
}); sendErr != nil {
writeError(w, http.StatusBadGateway, "agent_error", "failed to stream file chunk")
writeErr(w, r, apperr.HostUnreachable.Wrap(sendErr))
return
}
}
@ -127,7 +128,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
break
}
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file")
writeErr(w, r, apperr.Internal.WrapMsg(err, "Failed to read the uploaded file."))
return
}
}
@ -135,8 +136,7 @@ func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request
// Close and receive response.
streamClosed = true
if _, err := stream.CloseAndReceive(); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -156,17 +156,17 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
var req readFileRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -176,8 +176,7 @@ func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Reque
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
defer stream.Close()

View File

@ -5,6 +5,7 @@ import (
"connectrpc.com/connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -66,17 +67,17 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
var req listDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -86,8 +87,7 @@ func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
Depth: req.Depth,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -111,17 +111,17 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
var req makeDirRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -130,8 +130,7 @@ func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
Path: req.Path,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -150,17 +149,17 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
var req removeRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The path field is required.").With("field", "path"))
return
}
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -168,8 +167,7 @@ func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
SandboxId: sandboxIDStr,
Path: req.Path,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -187,7 +188,7 @@ func (h *hostHandler) isAdmin(r *http.Request, userID pgtype.UUID) bool {
func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createHostRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
@ -203,7 +204,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
if req.TeamID != "" {
teamID, err := id.ParseTeamID(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team_id")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID.").With("field", "team_id"))
return
}
params.TeamID = teamID
@ -211,8 +212,7 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
result, err := h.svc.Create(r.Context(), params)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -232,7 +232,7 @@ func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.List(r.Context(), ac.TeamID, admin)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -284,14 +284,13 @@ func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -306,14 +305,13 @@ func (h *hostHandler) DeletePreview(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
preview, err := h.svc.DeletePreview(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -333,7 +331,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
@ -353,18 +351,11 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
// Check if it's a "has running sandboxes" error and return a structured 409.
var hasSandboxes *service.HostHasSandboxesError
if errors.As(err, &hasSandboxes) {
writeJSON(w, http.StatusConflict, map[string]any{
"error": map[string]any{
"code": "has_active_sandboxes",
"message": "host has active sandboxes; use ?force=true to destroy them and delete the host",
"sandbox_ids": hasSandboxes.SandboxIDs,
},
})
writeErr(w, r, apperr.HostHasActiveSandboxes.Wrap(err).With("sandbox_ids", hasSandboxes.SandboxIDs))
return
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
}
// AdminList handles GET /v1/admin/hosts.
@ -372,7 +363,7 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) AdminList(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.ListAdmin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -422,14 +413,13 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -443,16 +433,16 @@ func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
var req registerHostRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
if req.Address == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "address is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The address field is required.").With("field", "address"))
return
}
@ -465,8 +455,7 @@ func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) {
Address: req.Address,
})
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -487,13 +476,13 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
// Prevent a host from heartbeating for a different host.
if hostID != hc.HostID {
writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch")
writeErr(w, r, apperr.Forbidden.Msg("Host ID does not match the authenticated host."))
return
}
@ -501,8 +490,7 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
prevHost, _ := h.queries.GetHost(r.Context(), hc.HostID)
if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -525,23 +513,22 @@ func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
var req addTagRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Tag == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "tag is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The tag field is required.").With("field", "tag"))
return
}
if err := h.svc.AddTag(r.Context(), hostID, ac.TeamID, admin, req.Tag); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -556,13 +543,12 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -574,18 +560,17 @@ func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) {
func (h *hostHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req refreshTokenRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.RefreshToken == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "refresh_token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The refresh_token field is required.").With("field", "refresh_token"))
return
}
result, err := h.svc.Refresh(r.Context(), req.RefreshToken)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -606,14 +591,13 @@ func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) {
hostID, err := id.ParseHostID(hostIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid host ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid host ID."))
return
}
tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID))
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -3,7 +3,6 @@ package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
@ -15,6 +14,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -22,6 +22,7 @@ import (
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
const (
@ -119,13 +120,13 @@ func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) {
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -154,13 +155,13 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
var req updateNameRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
if err := validate.DisplayName(req.Name); err != nil {
writeErr(w, r, apperr.ValidationFailed.WrapMsg(err, "Name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").With("field", "name"))
return
}
@ -168,7 +169,7 @@ func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
ID: ac.UserID,
Name: req.Name,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update name")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -188,46 +189,46 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var req changePasswordRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if user.PasswordHash.Valid {
// Changing existing password — verify current.
if req.CurrentPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The current_password field is required.").With("field", "current_password"))
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil {
writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect")
writeErr(w, r, apperr.AuthInvalidCredentials.WrapMsg(err, "Current password is incorrect."))
return
}
} else {
// OAuth user adding a password — confirm must match.
if req.ConfirmPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The confirm_password field is required.").With("field", "confirm_password"))
return
}
if req.NewPassword != req.ConfirmPassword {
writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match")
writeErr(w, r, apperr.ValidationFailed.Msg("Passwords do not match.").With("field", "confirm_password"))
return
}
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -235,7 +236,7 @@ func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ID: ac.UserID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -328,16 +329,16 @@ func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request)
func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
var req confirmPasswordResetRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The token field is required.").With("field", "token"))
return
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
writeErr(w, r, apperr.ValidationFailed.Msg("Password must be at least 8 characters.").With("field", "new_password"))
return
}
@ -349,29 +350,29 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
// preventing concurrent requests from both consuming the same token.
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
writeError(w, http.StatusBadRequest, "invalid_token", "reset token is invalid or has expired")
writeErr(w, r, apperr.AuthTokenInvalid.Wrap(err))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
user, err := h.db.GetUserByID(ctx, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -379,7 +380,7 @@ func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request)
ID: userID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -411,13 +412,13 @@ func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) {
p, ok := h.oauthRegistry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
state, err := generateState()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -455,19 +456,19 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
// Ensure the user will still have at least one login method after disconnecting.
if !user.PasswordHash.Valid && len(providers) <= 1 {
writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first")
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot disconnect your only login method — add a password first."))
return
}
@ -480,7 +481,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "provider not connected")
writeErr(w, r, apperr.NotFound.Msg("This provider is not connected to your account."))
return
}
@ -488,7 +489,7 @@ func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
UserID: ac.UserID,
Provider: provider,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -502,29 +503,28 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
var req deleteAccountRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) {
writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address")
writeErr(w, r, apperr.ValidationFailed.Msg("Confirmation does not match your email address.").With("field", "confirmation"))
return
}
teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if teamsBlocking > 0 {
writeError(w, http.StatusConflict, "owns_team_with_members",
fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking))
writeErr(w, r, apperr.Conflict.Msgf("You own %d team(s) with other members — transfer ownership or remove members before deleting your account.", teamsBlocking).With("owned_teams_with_members", teamsBlocking))
return
}
@ -534,20 +534,19 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
// DB-only cleanup in a transaction.
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
for _, teamID := range soleTeams {
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error",
fmt.Sprintf("failed to delete sole-owned team %s", id.FormatTeamID(teamID)))
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
}
tx, err := h.pool.Begin(ctx)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to start transaction")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
defer func() { _ = tx.Rollback(ctx) }()
@ -555,17 +554,17 @@ func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
qtx := h.db.WithTx(tx)
if err := qtx.DeleteAPIKeysByCreator(ctx, ac.UserID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete user's API keys")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := qtx.SoftDeleteUser(ctx, ac.UserID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if err := tx.Commit(ctx); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit account deletion")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -1,7 +1,6 @@
package api
import (
"context"
"net/http"
"time"
@ -9,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -46,7 +46,7 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
@ -56,13 +56,13 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
}
validRanges := map[string]bool{"5m": true, "10m": true, "1h": true, "2h": true, "6h": true, "12h": true, "24h": true}
if !validRanges[rangeTier] {
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h")
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 10m, 1h, 2h, 6h, 12h, 24h.").With("field", "range"))
return
}
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -70,9 +70,9 @@ func (h *sandboxMetricsHandler) GetMetrics(w http.ResponseWriter, r *http.Reques
case "running":
h.getFromAgent(w, r, sandboxIDStr, rangeTier, sb.HostID)
case "paused":
h.getFromDB(ctx, w, sandboxIDStr, sandboxID, rangeTier)
h.getFromDB(w, r, sandboxIDStr, sandboxID, rangeTier)
default:
writeError(w, http.StatusNotFound, "not_found", "metrics not available for sandbox in state: "+sb.Status)
writeErr(w, r, apperr.NotFound.Msg("Metrics are not available for a sandbox in state "+sb.Status+".").With("status", sb.Status))
}
}
@ -81,7 +81,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
agent, err := agentForHost(ctx, h.db, h.pool, hostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -90,8 +90,7 @@ func (h *sandboxMetricsHandler) getFromAgent(w http.ResponseWriter, r *http.Requ
Range: rangeTier,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -126,7 +125,8 @@ var rangeToDB = map[string]struct {
"24h": {"24h", 24 * time.Hour},
}
func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWriter, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
func (h *sandboxMetricsHandler) getFromDB(w http.ResponseWriter, r *http.Request, sandboxIDStr string, sandboxID pgtype.UUID, rangeTier string) {
ctx := r.Context()
mapping := rangeToDB[rangeTier]
rows, err := h.db.GetSandboxMetricPoints(ctx, db.GetSandboxMetricPointsParams{
SandboxID: sandboxID,
@ -134,7 +134,7 @@ func (h *sandboxMetricsHandler) getFromDB(ctx context.Context, w http.ResponseWr
Ts: time.Now().Add(-mapping.cutoff).Unix(),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to read metrics")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -17,11 +17,14 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
type oauthHandler struct {
@ -51,13 +54,13 @@ func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
p, ok := h.registry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
state, err := generateState()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -88,7 +91,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
p, ok := h.registry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
writeErr(w, r, apperr.NotFound.Msg("Unsupported OAuth provider."))
return
}
@ -270,6 +273,18 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
// A brand-new account: the email and provider-supplied name enter our
// database here. Validate the email, and coerce the name into the safe
// display-name charset (OAuth names cannot be rejected interactively, so
// they are cleaned rather than refused).
if err := validate.Email(email); err != nil {
slog.Warn("oauth: provider returned an invalid email", "provider", provider)
redirectWithError(w, r, redirectBase, "invalid_email")
return
}
emailLocal, _, _ := strings.Cut(email, "@")
displayName := validate.SanitizeDisplayName(profile.Name, validate.SanitizeDisplayName(emailLocal, "user"))
// New OAuth identity — check for email collision.
existingUser, err := h.db.GetUserByEmail(ctx, email)
if err == nil {
@ -315,7 +330,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
ID: userID,
Email: email,
Name: profile.Name,
Name: displayName,
})
if err != nil {
var pgErr *pgconn.PgError
@ -332,7 +347,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
}
teamID := id.NewTeamID()
teamName := profile.Name + "'s Team"
teamName := displayName + "'s Team"
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
ID: teamID,
Name: teamName,
@ -397,7 +412,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
Secure: isSecure(r),
})
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, profile.Name, "owner", isFirstUser, redirectBase); err != nil {
if err := h.issueSessionAndRedirect(w, r, userID, teamID, email, displayName, "owner", isFirstUser, redirectBase); err != nil {
slog.Error("oauth: failed to issue session", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
return
@ -461,7 +476,7 @@ func (h *oauthHandler) issueSessionAndRedirect(
isAdmin bool,
redirectBase string,
) error {
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), clientIP(r))
sess, err := h.sessions.Create(r.Context(), userID, teamID, email, name, role, isAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return err
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -52,7 +53,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -60,8 +61,7 @@ func (h *processHandler) ListProcesses(w http.ResponseWriter, r *http.Request) {
SandboxId: sandboxIDStr,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -92,7 +92,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
writeErr(w, r, apperr.HostUnreachable.Wrap(err))
return
}
@ -112,8 +112,7 @@ func (h *processHandler) KillProcess(w http.ResponseWriter, r *http.Request) {
}
if _, err := agent.KillProcess(ctx, connect.NewRequest(killReq)); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -128,7 +127,7 @@ func (h *processHandler) ConnectProcess(w http.ResponseWriter, r *http.Request)
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -13,6 +13,7 @@ import (
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -108,7 +109,7 @@ func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -84,13 +85,13 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
ac := auth.MustFromContext(r.Context())
if !ac.TeamID.Valid {
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
writeErr(w, r, apperr.Forbidden.Msg("No active team context; re-authenticate."))
return
}
@ -107,8 +108,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
if sb.ID.Valid {
h.audit.LogSandboxDestroySystem(r.Context(), ac.TeamID, sb.ID, "cleanup_after_create_error", nil)
}
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -120,7 +120,7 @@ func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
sandboxes, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -139,13 +139,13 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
writeErr(w, r, apperr.SandboxNotFound.Wrap(err))
return
}
@ -167,15 +167,14 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxPause(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -189,15 +188,14 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxResume(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -211,13 +209,12 @@ func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -231,15 +228,14 @@ func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
err = h.svc.Destroy(r.Context(), sandboxID, ac.TeamID)
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID, err)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/channels"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -42,19 +43,19 @@ type sandboxEventRequest struct {
func (h *sandboxEventHandler) Handle(w http.ResponseWriter, r *http.Request) {
var req sandboxEventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.Event == "" || req.SandboxID == "" || req.HostID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "event, sandbox_id, and host_id are required")
writeErr(w, r, apperr.ValidationFailed.Msg("The event, sandbox_id, and host_id fields are required."))
return
}
hc := auth.MustHostFromContext(r.Context())
callerHostID := id.FormatHostID(hc.HostID)
if callerHostID != req.HostID {
writeError(w, http.StatusForbidden, "forbidden", "host_id does not match authenticated host")
writeErr(w, r, apperr.Forbidden.Msg("The host_id does not match the authenticated host."))
return
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
)
@ -26,7 +27,7 @@ func (h *sessionsHandler) List(w http.ResponseWriter, r *http.Request) {
rows, err := h.sessions.ListForUser(r.Context(), ac.UserID)
if err != nil {
slog.Error("list sessions: db error", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sessions")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
out := make([]sessionRow, 0, len(rows))
@ -51,12 +52,12 @@ func (h *sessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
sid := chi.URLParam(r, "id")
if sid == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "missing session id")
writeErr(w, r, apperr.InvalidRequest.Msg("The session id is required."))
return
}
if err := h.sessions.DeleteForUser(r.Context(), sid, ac.UserID); err != nil {
slog.Error("delete session: db error", "error", err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete session")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if sid == ac.SessionID {

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
@ -119,16 +120,16 @@ type createSnapshotRequest struct {
func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createSnapshotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if req.SandboxID == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "sandbox_id is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The sandbox_id field is required.").With("field", "sandbox_id"))
return
}
sandboxID, err := id.ParseSandboxID(req.SandboxID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid sandbox ID."))
return
}
ac := auth.MustFromContext(r.Context())
@ -138,8 +139,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
// result via the SSE template.snapshot.create event (or by polling).
sb, name, err := h.sandboxSvc.CreateSnapshot(r.Context(), sandboxID, ac.TeamID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
h.audit.LogSnapshotCreateRequested(r.Context(), ac, name)
@ -154,7 +154,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -175,7 +175,7 @@ func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
if err := validate.SafeName(name); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid snapshot name: %s", err))
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid snapshot name."))
return
}
ctx := r.Context()
@ -183,29 +183,28 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID})
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "template not found")
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
return
}
// Platform templates can only be deleted by admins via /v1/admin/templates.
if tmpl.TeamID == id.PlatformTeamID {
writeError(w, http.StatusForbidden, "forbidden", "platform templates cannot be deleted here")
writeErr(w, r, apperr.TemplateProtected.Msg("Platform templates cannot be deleted here."))
return
}
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
writeError(w, http.StatusForbidden, "forbidden", "system base templates cannot be deleted")
writeErr(w, r, apperr.TemplateProtected.New())
return
}
if err := deleteSnapshotEverywhere(ctx, h.db, h.pool, tmpl.TeamID, tmpl.ID); err != nil {
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
writeError(w, http.StatusConflict, "delete_failed",
"could not remove snapshot files from all hosts: "+err.Error())
writeErr(w, r, apperr.Conflict.WrapMsg(err, "Could not remove snapshot files from all hosts. Try again when all hosts are online."))
return
}
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil {
h.audit.LogSnapshotDelete(r.Context(), ac, name, err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
@ -25,7 +26,7 @@ func newSSEHandler(broker *SSEBroker) *sseHandler {
func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
writeErr(w, r, apperr.AuthSessionRequired.New())
return
}
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), false)
@ -36,7 +37,7 @@ func (h *sseHandler) Stream(w http.ResponseWriter, r *http.Request) {
func (h *sseHandler) AdminStream(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "admin session required")
writeErr(w, r, apperr.Unauthorized.Msg("An admin session is required."))
return
}
h.serveSSE(w, r, id.FormatTeamID(ac.TeamID), true)

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
@ -53,14 +54,14 @@ func (h *statsHandler) GetStats(w http.ResponseWriter, r *http.Request) {
}
tr := service.TimeRange(rangeParam)
if !service.ValidRange(tr) {
writeError(w, http.StatusBadRequest, "invalid_request", "range must be one of: 5m, 1h, 6h, 24h, 30d")
writeErr(w, r, apperr.ValidationFailed.Msg("The range parameter must be one of: 5m, 1h, 6h, 24h, 30d.").With("field", "range"))
return
}
current, peaks, series, err := h.svc.GetStats(r.Context(), ac.TeamID, tr)
if err != nil {
slog.Error("stats handler: get stats failed", "team_id", ac.TeamID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve stats")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -84,11 +85,11 @@ func requireTeamAccess(w http.ResponseWriter, r *http.Request, ac auth.AuthConte
teamIDStr := chi.URLParam(r, "id")
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return pgtype.UUID{}, false
}
if ac.TeamID != teamID {
writeError(w, http.StatusForbidden, "forbidden", "JWT team does not match requested team; use switch-team first")
writeErr(w, r, apperr.Forbidden.Msg("Your active team does not match the requested team. Switch teams first."))
return pgtype.UUID{}, false
}
return teamID, true
@ -101,8 +102,7 @@ func (h *teamHandler) List(w http.ResponseWriter, r *http.Request) {
teams, err := h.svc.ListTeamsForUser(r.Context(), ac.UserID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -125,15 +125,14 @@ func (h *teamHandler) Create(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
team, err := h.svc.CreateTeam(r.Context(), ac.UserID, req.Name)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -163,15 +162,13 @@ func (h *teamHandler) Get(w http.ResponseWriter, r *http.Request) {
team, err := h.svc.GetTeam(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
members, err := h.svc.GetMembers(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -199,7 +196,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Name = strings.TrimSpace(req.Name)
@ -211,8 +208,7 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -230,8 +226,7 @@ func (h *teamHandler) Delete(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.DeleteTeam(r.Context(), teamID, ac.UserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -248,8 +243,7 @@ func (h *teamHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
members, err := h.svc.GetMembers(r.Context(), teamID)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -273,19 +267,18 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "email is required")
writeErr(w, r, apperr.ValidationFailed.Msg("The email field is required.").With("field", "email"))
return
}
member, err := h.svc.AddMember(r.Context(), teamID, ac.UserID, req.Email)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -324,13 +317,12 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
if err := h.svc.RemoveMember(r.Context(), teamID, ac.UserID, targetUserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -350,7 +342,7 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
targetUserID, err := id.ParseUserID(targetUserIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -358,13 +350,12 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.UpdateMemberRole(r.Context(), teamID, ac.UserID, targetUserID, req.Role); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -387,8 +378,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
}
if err := h.svc.LeaveTeam(r.Context(), teamID, ac.UserID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -404,7 +394,7 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return
}
@ -412,13 +402,12 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if err := h.svc.SetBYOC(r.Context(), teamID, req.Enabled); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -440,8 +429,7 @@ func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) {
teams, total, err := h.svc.AdminListTeams(r.Context(), perPage, offset)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -502,13 +490,12 @@ func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) {
teamID, err := id.ParseTeamID(teamIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid team ID."))
return
}
if err := h.svc.AdminDeleteTeam(r.Context(), teamID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
@ -41,7 +42,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
var err error
from, err = time.Parse("2006-01-02", s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "from must be YYYY-MM-DD")
writeErr(w, r, apperr.ValidationFailed.Msg("The from parameter must be YYYY-MM-DD.").With("field", "from"))
return
}
} else {
@ -52,7 +53,7 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
var err error
to, err = time.Parse("2006-01-02", s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "to must be YYYY-MM-DD")
writeErr(w, r, apperr.ValidationFailed.Msg("The to parameter must be YYYY-MM-DD.").With("field", "to"))
return
}
} else {
@ -60,18 +61,18 @@ func (h *usageHandler) GetUsage(w http.ResponseWriter, r *http.Request) {
}
if from.After(to) {
writeError(w, http.StatusBadRequest, "invalid_request", "from must be before or equal to to")
writeErr(w, r, apperr.ValidationFailed.Msg("The from date must be before or equal to the to date."))
return
}
if to.Sub(from).Hours()/24 > 92 {
writeError(w, http.StatusBadRequest, "invalid_request", "range cannot exceed 92 days")
writeErr(w, r, apperr.ValidationFailed.Msg("The range cannot exceed 92 days."))
return
}
points, err := h.svc.GetUsage(r.Context(), ac.TeamID, from, to)
if err != nil {
slog.Error("usage handler: get usage failed", "team_id", ac.TeamID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "failed to retrieve usage")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}

View File

@ -2,6 +2,7 @@ package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
@ -9,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
@ -36,7 +38,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
if len(prefix) < 3 || !strings.Contains(prefix, "@") {
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
writeErr(w, r, apperr.ValidationFailed.Msg("The email prefix must be at least 3 characters and contain '@'.").With("field", "email"))
return
}
@ -45,7 +47,7 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "search failed")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
@ -74,8 +76,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
writeErr(w, r, err)
return
}
@ -122,7 +123,7 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -130,12 +131,12 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
Active bool `json:"active"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
if ac.UserID == userID && !req.Active {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account")
writeErr(w, r, apperr.InvalidRequest.Msg("You cannot deactivate your own account."))
return
}
@ -147,22 +148,25 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
// Look up user email for audit log before changing status.
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
writeErr(w, r, apperr.UserNotFound.Wrap(err))
return
}
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
httpStatus, code, msg := serviceErrToHTTP(err)
writeError(w, httpStatus, code, msg)
writeErr(w, r, err)
return
}
if req.Active {
h.audit.LogUserActivate(r.Context(), ac, userID, user.Email)
} else {
// Disabled users must be kicked out of every active session.
// Disabled users must be kicked out of every active session. A failure
// here leaves the account's cached sessions live (Session.Get only
// re-checks user status on a cache miss), so it must be surfaced loudly
// rather than swallowed — an operator may need to force revocation.
if err := h.sessions.RevokeAllForUser(r.Context(), userID); err != nil {
_ = err
slog.Error("deactivate user: revoke sessions failed",
"user_id", id.FormatUserID(userID), "error", err)
}
h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email)
}
@ -177,7 +181,7 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid user ID."))
return
}
@ -185,13 +189,13 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
Admin bool `json:"admin"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
return
}
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
writeErr(w, r, apperr.UserNotFound.Wrap(err))
return
}
@ -205,18 +209,18 @@ func (h *usersHandler) SetUserAdmin(w http.ResponseWriter, r *http.Request) {
ID: userID,
IsAdmin: true,
}); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email)
} else {
affected, err := h.db.RevokeUserAdmin(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if affected == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin")
writeErr(w, r, apperr.InvalidRequest.Msg("Cannot remove the last admin."))
return
}
h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email)

View File

@ -3,39 +3,29 @@ package api
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
type errorResponse struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{
Error: errorDetail{Code: code, Message: message},
})
// writeErr resolves any error to its client-safe envelope and writes it,
// logging the cause chain keyed by request ID. Handlers construct errors
// from the pkg/apperr catalog.
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
apperr.WriteHTTP(w, r, err)
}
// formatUUIDForRPC converts a pgtype.UUID to a hex string for RPC messages.
@ -43,32 +33,6 @@ func formatUUIDForRPC(u pgtype.UUID) string {
return id.UUIDString(u)
}
// agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message.
func agentErrToHTTP(err error) (int, string, string) {
switch connect.CodeOf(err) {
case connect.CodeNotFound:
return http.StatusNotFound, "not_found", err.Error()
case connect.CodeInvalidArgument:
return http.StatusBadRequest, "invalid_request", err.Error()
case connect.CodeAlreadyExists:
return http.StatusConflict, "already_exists", err.Error()
case connect.CodeFailedPrecondition:
return http.StatusConflict, "conflict", err.Error()
case connect.CodePermissionDenied:
return http.StatusForbidden, "forbidden", err.Error()
case connect.CodeUnavailable:
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
case connect.CodeUnimplemented:
return http.StatusNotImplemented, "agent_error", err.Error()
case connect.CodeDeadlineExceeded:
return http.StatusGatewayTimeout, "timeout", "command timed out"
case connect.CodeInternal:
return http.StatusInternalServerError, "agent_error", err.Error()
default:
return http.StatusBadGateway, "agent_error", err.Error()
}
}
// requestLogger returns middleware that logs each request.
func requestLogger() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@ -77,6 +41,7 @@ func requestLogger() func(http.Handler) http.Handler {
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
slog.Info("request",
"request_id", apperr.RequestID(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
@ -90,45 +55,6 @@ func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}
// serviceErrToHTTP maps a service-layer error to an HTTP status, code, and message.
// It inspects the underlying Connect RPC error if present, otherwise returns 500.
func serviceErrToHTTP(err error) (int, string, string) {
msg := err.Error()
// Check for Connect RPC errors wrapped by the service layer.
var connectErr *connect.Error
if errors.As(err, &connectErr) {
return agentErrToHTTP(connectErr)
}
// Map well-known service error patterns.
// Return generic messages for most cases to avoid leaking internal details.
switch {
case strings.Contains(msg, "not found"):
return http.StatusNotFound, "not_found", "resource not found"
case strings.Contains(msg, "not running"):
return http.StatusConflict, "invalid_state", "resource is not running"
case strings.Contains(msg, "not paused"):
return http.StatusConflict, "invalid_state", "resource is not paused"
case strings.Contains(msg, "conflict:"):
return http.StatusConflict, "conflict", strings.TrimPrefix(msg, "conflict: ")
case strings.Contains(msg, "forbidden"):
return http.StatusForbidden, "forbidden", "forbidden"
case strings.Contains(msg, "invalid or expired"):
return http.StatusUnauthorized, "unauthorized", "invalid or expired credentials"
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
strings.Contains(msg, "no host has sufficient resources"):
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
case strings.HasPrefix(msg, "invalid metadata: "):
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
case strings.Contains(msg, "invalid"):
return http.StatusBadRequest, "invalid_request", "invalid request"
default:
slog.Error("unhandled service error", "error", err)
return http.StatusInternalServerError, "internal_error", "an internal error occurred"
}
}
type statusWriter struct {
http.ResponseWriter
status int

View File

@ -3,6 +3,7 @@ package api
import (
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -35,12 +36,18 @@ func requireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
writeErr(w, r, apperr.Unauthorized.New())
return
}
user, err := queries.GetUserByID(r.Context(), ac.UserID)
if err != nil || !user.IsAdmin {
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
if err != nil {
// A transient DB failure is not an authorization decision — a
// legitimate admin must not see "Admin access required" (403).
writeErr(w, r, apperr.Internal.Wrap(err))
return
}
if !user.IsAdmin {
writeErr(w, r, apperr.Forbidden.Msg("Admin access required."))
return
}
next.ServeHTTP(w, r)

View File

@ -4,6 +4,7 @@ import (
"crypto/subtle"
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
)
@ -29,7 +30,7 @@ func requireCSRF() func(http.Handler) http.Handler {
header := r.Header.Get("X-CSRF-Token")
if err != nil || cookie.Value == "" || header == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
writeErr(w, r, apperr.AuthCSRF.New())
return
}
next.ServeHTTP(w, r)

View File

@ -3,6 +3,7 @@ package api
import (
"net/http"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/id"
)
@ -14,19 +15,19 @@ func requireHostToken(secret []byte) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := r.Header.Get("X-Host-Token")
if tokenStr == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "X-Host-Token header required")
writeErr(w, r, apperr.Unauthorized.Msg("The X-Host-Token header is required."))
return
}
claims, err := auth.VerifyHostJWT(secret, tokenStr)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired host token")
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token is invalid or has expired."))
return
}
hostID, err := id.ParseHostID(claims.HostID)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid host ID in token")
writeErr(w, r, apperr.Unauthorized.WrapMsg(err, "Host token carries an invalid host ID."))
return
}

View File

@ -3718,7 +3718,7 @@ components:
$ref: "#/components/schemas/Error"
ServiceUnavailable:
description: The host serving this capsule is unreachable (host_unavailable)
description: Temporarily unavailable — no capacity (capacity_unavailable), host draining (host_draining), or the capsule's host is unreachable (host_unreachable, returned as 502)
content:
application/json:
schema:
@ -4426,21 +4426,13 @@ components:
description: IDs of capsules that would be destroyed on force-delete.
HostHasCapsulesError:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: has_active_sandboxes
message:
type: string
sandbox_ids:
type: array
items:
type: string
description: IDs of active capsules blocking deletion.
allOf:
- $ref: "#/components/schemas/Error"
description: |
Standard error envelope returned by host delete (409) when the host
still has active capsules. `error.code` is `has_active_sandboxes` and
`error.details.sandbox_ids` lists the IDs of the capsules blocking
deletion — pass `?force=true` to destroy them and delete the host.
AddTagRequest:
type: object
@ -4724,14 +4716,37 @@ components:
Error:
type: object
description: |
Standard error envelope returned by every non-2xx JSON response.
`code` is a stable machine-readable identifier (snake_case, e.g.
`sandbox_not_running`, `capacity_unavailable`, `internal_error`).
`message` is human-readable and safe to display. `request_id` matches
the `X-Request-ID` response header and the server logs — quote it when
reporting issues. `retryable` indicates the same request may succeed
if retried. `details` carries optional machine-readable context
(e.g. `{"status": "paused"}` on `sandbox_not_running`, or
`{"field": "path"}` on `validation_failed`).
properties:
error:
type: object
required: [code, message, retryable]
properties:
code:
type: string
example: sandbox_not_running
message:
type: string
example: Sandbox is not running.
request_id:
type: string
example: req_8f3ka92b1c04d7e6
retryable:
type: boolean
example: false
details:
type: object
additionalProperties: true
example: { status: paused }
AuditLogEntry:
type: object

View File

@ -307,15 +307,20 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
return
}
action := "create"
// The lifecycle op that failed is recorded as a distinct "error" action
// (attributed to system) rather than a second "create"/"resume" row, so the
// audit log reads "a capsule encountered an error" instead of masquerading
// as a duplicate, system-attributed creation. The user-attributed create row
// was already written at request-accept time by the handler.
phase := "create"
if event.Event == events.CapsuleResume {
action = "resume"
phase = "resume"
}
reason := event.Metadata["reason"]
if reason == "" {
reason = action + "_failed"
reason = phase + "_failed"
}
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": phase}
if event.Error != "" {
meta["error"] = event.Error
}
@ -325,7 +330,7 @@ func (c *SandboxEventConsumer) handleFailed(ctx context.Context, sandboxID pgtyp
ActorType: "system",
ResourceType: "sandbox",
ResourceID: id.FormatSandboxID(sandboxID),
Action: action,
Action: "error",
Scope: "team",
Status: "error",
Metadata: meta,

View File

@ -11,6 +11,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
@ -61,6 +62,7 @@ func New(
version string,
) *Server {
r := chi.NewRouter()
r.Use(apperr.Middleware)
r.Use(requestLogger())
// Apply extension middleware before routes so it wraps all OSS routes.

View File

@ -155,6 +155,15 @@ func impliedSandboxStatus(event events.Event) (string, bool) {
if event.Outcome != events.OutcomeSuccess {
return "", false
}
// Only system-initiated completion events (host callbacks, TTL reaper) imply
// a terminal status. User/API-key create/resume/pause events are published at
// request-accept time — while the sandbox is still transient (starting /
// resuming / pausing) — so their hydrated DB row is authoritative and must
// not be overridden. Overriding them masks the transient state on the
// dashboard (a launching capsule jumps straight to "running").
if event.Actor.Type != events.ActorSystem {
return "", false
}
switch event.Event {
case events.CapsulePause:
return "paused", true

View File

@ -74,13 +74,6 @@ func LoadTokenFile(path string) (*TokenFile, error) {
if err != nil {
return nil, err
}
// Support legacy format (raw JWT string) for backwards compatibility.
trimmed := strings.TrimSpace(string(data))
if !strings.HasPrefix(trimmed, "{") {
// Old format: just the JWT, no refresh token.
hostID, _ := hostIDFromJWT(trimmed)
return &TokenFile{HostID: hostID, JWT: trimmed}, nil
}
var tf TokenFile
if err := json.Unmarshal(data, &tf); err != nil {
return nil, fmt.Errorf("parse credentials file: %w", err)

View File

@ -19,7 +19,9 @@ import (
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
"git.omukk.dev/wrenn/wrenn/pkg/network"
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
)
@ -56,11 +58,11 @@ func parseUUIDString(s string) (pgtype.UUID, error) {
func parseSandboxIDs(teamIDStr, templateIDStr string) (teamID, templateID pgtype.UUID, err error) {
teamID, err = parseUUIDString(teamIDStr)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
}
templateID, err = parseUUIDString(templateIDStr)
if err != nil {
return pgtype.UUID{}, pgtype.UUID{}, connect.NewError(connect.CodeInvalidArgument, err)
return pgtype.UUID{}, pgtype.UUID{}, apperr.ToConnect(apperr.InvalidRequest.Wrap(err))
}
return teamID, templateID, nil
}
@ -83,10 +85,7 @@ func (s *Server) CreateSandbox(
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), 0,
msg.DefaultUser, msg.DefaultEnv)
if err != nil {
if errors.Is(err, sandbox.ErrDraining) {
return nil, connect.NewError(connect.CodeUnavailable, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
return nil, mapSandboxError(fmt.Errorf("create sandbox: %w", err))
}
return connect.NewResponse(&pb.CreateSandboxResponse{
@ -183,21 +182,32 @@ func (s *Server) FlattenRootfs(
}), nil
}
// mapSandboxError translates sandbox.Manager errors to Connect error codes
// via sentinel errors (errors.Is). Adding a new precondition sentinel in the
// sandbox package only requires extending this switch — no string sniffing.
// mapSandboxError translates sandbox.Manager errors to Connect errors
// carrying an apperr ErrorInfo detail, so the control plane surfaces the
// precise catalog error instead of guessing from the Connect code. Sentinels
// are matched with errors.Is — no string sniffing. Unmatched errors become
// internal_error: the full cause crosses the (internal) RPC channel for CP
// logs, while the client-safe detail stays generic.
func mapSandboxError(err error) error {
switch {
case errors.Is(err, sandbox.ErrNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, sandbox.ErrNotRunning), errors.Is(err, sandbox.ErrNotPaused):
return connect.NewError(connect.CodeFailedPrecondition, err)
return apperr.ToConnect(apperr.SandboxNotFound.Wrap(err))
case errors.Is(err, sandbox.ErrNotRunning):
return apperr.ToConnect(apperr.SandboxNotRunning.Wrap(err))
case errors.Is(err, sandbox.ErrNotPaused):
return apperr.ToConnect(apperr.SandboxNotPaused.Wrap(err))
case errors.Is(err, sandbox.ErrDraining):
return connect.NewError(connect.CodeUnavailable, err)
return apperr.ToConnect(apperr.HostDraining.Wrap(err))
case errors.Is(err, sandbox.ErrInvalidRange):
return connect.NewError(connect.CodeInvalidArgument, err)
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(err, "Invalid metrics range."))
case errors.Is(err, sandbox.ErrTemplateNotFound):
return apperr.ToConnect(apperr.TemplateNotFound.WrapMsg(err, "The template's rootfs image is not available on this host."))
case errors.Is(err, sandbox.ErrEnvdNotReady):
return apperr.ToConnect(apperr.SandboxUnresponsive.WrapMsg(err, "The sandbox VM started but its agent did not become ready."))
case errors.Is(err, network.ErrNoFreeSlots):
return apperr.ToConnect(apperr.CapacityUnavailable.WrapMsg(err, "This host has no free network slots. Try again shortly."))
default:
return connect.NewError(connect.CodeInternal, err)
return apperr.ToConnect(err)
}
}
@ -212,9 +222,9 @@ func (s *Server) GetTemplateSize(
size, err := s.mgr.TemplateRootfsSize(teamID, templateID)
if err != nil {
if os.IsNotExist(err) {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, apperr.ToConnect(apperr.TemplateNotFound.Wrap(err))
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("get template size: %w", err))
return nil, mapSandboxError(fmt.Errorf("get template size: %w", err))
}
return connect.NewResponse(&pb.GetTemplateSizeResponse{
SizeBytes: size,
@ -226,10 +236,7 @@ func (s *Server) PingSandbox(
req *connect.Request[pb.PingSandboxRequest],
) (*connect.Response[pb.PingSandboxResponse], error) {
if err := s.mgr.Ping(req.Msg.SandboxId); err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeFailedPrecondition, err)
return nil, mapSandboxError(err)
}
return connect.NewResponse(&pb.PingSandboxResponse{}), nil
}
@ -265,15 +272,42 @@ func (s *Server) Exec(
}), nil
}
// envdErr propagates an error from the envd client, preserving its Connect
// error code (e.g. AlreadyExists, NotFound) so the control plane maps it to
// the correct HTTP status. Non-Connect errors fall back to CodeInternal.
// envdErr propagates an error from the envd client. envd is a separate binary
// that never attaches an apperr ErrorInfo detail, so its Connect code is the
// only signal — and the control plane's fromConnect flattens any detail-less
// error to internal_error. So client-fault codes are translated to their
// catalog Def here, keeping envd's message (it describes the caller's own
// request, e.g. a bad path inside their sandbox); without this a missing file
// would surface as a misleading 500 instead of a 404. Transport failures
// reaching envd become sandbox_unresponsive, everything else internal_error
// with the cause preserved for CP logs.
func envdErr(action string, err error) error {
code := connect.CodeOf(err)
if code == connect.CodeUnknown {
code = connect.CodeInternal
wrapped := fmt.Errorf("%s: %w", action, err)
switch connect.CodeOf(err) {
case connect.CodeNotFound:
return apperr.ToConnect(apperr.NotFound.WrapMsg(wrapped, connectMessage(err)))
case connect.CodeInvalidArgument:
return apperr.ToConnect(apperr.InvalidRequest.WrapMsg(wrapped, connectMessage(err)))
case connect.CodeAlreadyExists:
return apperr.ToConnect(apperr.Conflict.WrapMsg(wrapped, connectMessage(err)))
case connect.CodePermissionDenied:
return apperr.ToConnect(apperr.Forbidden.Wrap(wrapped))
case connect.CodeUnavailable:
return apperr.ToConnect(apperr.SandboxUnresponsive.Wrap(wrapped))
default:
return apperr.ToConnect(wrapped)
}
return connect.NewError(code, fmt.Errorf("%s: %w", action, err))
}
// connectMessage returns the human-readable message from a Connect error. envd
// phrases its client-fault messages in terms of the caller's own request
// ("path not found"), so they are safe to surface to API clients.
func connectMessage(err error) string {
var ce *connect.Error
if errors.As(err, &ce) {
return ce.Message()
}
return "The request could not be completed."
}
func (s *Server) WriteFile(
@ -284,7 +318,7 @@ func (s *Server) WriteFile(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
if err := client.WriteFile(ctx, msg.Path, msg.Content); err != nil {
@ -302,7 +336,7 @@ func (s *Server) ReadFile(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
content, err := client.ReadFile(ctx, msg.Path)
@ -321,7 +355,7 @@ func (s *Server) ListDir(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
@ -345,7 +379,7 @@ func (s *Server) MakeDir(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
resp, err := client.MakeDir(ctx, msg.Path)
@ -366,7 +400,7 @@ func (s *Server) RemovePath(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
if err := client.Remove(ctx, msg.Path); err != nil {
@ -393,7 +427,7 @@ func (s *Server) ExecStream(
events, err := s.mgr.ExecStream(execCtx, msg.SandboxId, msg.Cmd, msg.Args...)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("exec stream: %w", err))
return mapSandboxError(fmt.Errorf("exec stream: %w", err))
}
for ev := range events {
@ -442,20 +476,20 @@ func (s *Server) WriteFileStream(
// First message must contain metadata.
if !stream.Receive() {
if err := stream.Err(); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
return nil, apperr.ToConnect(err)
}
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("empty stream"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The upload stream was empty."))
}
first := stream.Msg()
meta := first.GetMeta()
if meta == nil {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("first message must contain metadata"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("The first stream message must contain metadata."))
}
client, err := s.mgr.GetClient(meta.SandboxId)
if err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
return nil, mapSandboxError(err)
}
// Use io.Pipe to stream raw chunks into envd's REST endpoint body.
@ -484,18 +518,18 @@ func (s *Server) WriteFileStream(
errCh <- nil
}()
// Send the raw streaming body to envd.
// Send the raw streaming body to envd. No username is sent, so envd resolves
// the sandbox default user — the same user process execution runs as.
base := client.BaseURL()
u := fmt.Sprintf("%s/files?%s", base, url.Values{
"path": {meta.Path},
"username": {"root"},
}.Encode())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, pr)
if err != nil {
pw.CloseWithError(err)
<-errCh
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
return nil, apperr.ToConnect(fmt.Errorf("create request: %w", err))
}
httpReq.Header.Set("Content-Type", "application/octet-stream")
@ -503,18 +537,18 @@ func (s *Server) WriteFileStream(
if err != nil {
pw.CloseWithError(err)
<-errCh
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("write file stream: %w", err))
return nil, envdErr("write file stream", err)
}
defer resp.Body.Close()
// Wait for the writer goroutine.
if writerErr := <-errCh; writerErr != nil {
return nil, connect.NewError(connect.CodeInternal, writerErr)
return nil, apperr.ToConnect(writerErr)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
return nil, envdErr("write file stream", fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body)))
}
slog.Debug("streaming file write complete", "sandbox_id", meta.SandboxId, "path", meta.Path)
@ -530,29 +564,30 @@ func (s *Server) ReadFileStream(
client, err := s.mgr.GetClient(msg.SandboxId)
if err != nil {
return connect.NewError(connect.CodeNotFound, err)
return mapSandboxError(err)
}
// No username is sent, so envd resolves the sandbox default user, matching
// process execution.
base := client.BaseURL()
u := fmt.Sprintf("%s/files?%s", base, url.Values{
"path": {msg.Path},
"username": {"root"},
}.Encode())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
return apperr.ToConnect(fmt.Errorf("create request: %w", err))
}
resp, err := client.StreamingHTTPClient().Do(httpReq)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("read file stream: %w", err))
return envdErr("read file stream", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return connect.NewError(connect.CodeInternal, fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
return envdErr("read file stream", fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body)))
}
// Stream file content in 64KB chunks.
@ -580,7 +615,7 @@ func (s *Server) ReadFileStream(
break
}
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("read body: %w", err))
return envdErr("read file stream", fmt.Errorf("read body: %w", err))
}
}
@ -687,7 +722,7 @@ func (s *Server) PtyAttach(
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
return mapSandboxError(fmt.Errorf("pty attach: %w", err))
}
for ev := range events {
@ -723,7 +758,7 @@ func (s *Server) PtySendInput(
msg := req.Msg
if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty send input: %w", err))
}
return connect.NewResponse(&pb.PtySendInputResponse{}), nil
@ -736,7 +771,7 @@ func (s *Server) PtyResize(
msg := req.Msg
if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty resize: %w", err))
}
return connect.NewResponse(&pb.PtyResizeResponse{}), nil
@ -749,7 +784,7 @@ func (s *Server) PtyKill(
msg := req.Msg
if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err))
return nil, mapSandboxError(fmt.Errorf("pty kill: %w", err))
}
return connect.NewResponse(&pb.PtyKillResponse{}), nil
@ -805,10 +840,7 @@ func (s *Server) StartBackground(
pid, err := s.mgr.StartBackground(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Envs, msg.Cwd)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("start background: %w", err))
return nil, mapSandboxError(fmt.Errorf("start background: %w", err))
}
return connect.NewResponse(&pb.StartBackgroundResponse{
@ -823,10 +855,7 @@ func (s *Server) ListProcesses(
) (*connect.Response[pb.ListProcessesResponse], error) {
procs, err := s.mgr.ListProcesses(ctx, req.Msg.SandboxId)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list processes: %w", err))
return nil, mapSandboxError(fmt.Errorf("list processes: %w", err))
}
entries := make([]*pb.ProcessEntry, 0, len(procs))
@ -859,7 +888,7 @@ func (s *Server) KillProcess(
case *pb.KillProcessRequest_Tag:
tag = sel.Tag
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
}
// Map signal string to envd enum.
@ -870,14 +899,11 @@ func (s *Server) KillProcess(
case "SIGTERM":
signal = envdpb.Signal_SIGNAL_SIGTERM
default:
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unsupported signal: %s (use SIGKILL or SIGTERM)", msg.Signal))
return nil, apperr.ToConnect(apperr.InvalidRequest.Msgf("Unsupported signal: %s (use SIGKILL or SIGTERM).", msg.Signal))
}
if err := s.mgr.KillProcess(ctx, msg.SandboxId, pid, tag, signal); err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("kill process: %w", err))
return nil, mapSandboxError(fmt.Errorf("kill process: %w", err))
}
return connect.NewResponse(&pb.KillProcessResponse{}), nil
@ -898,15 +924,12 @@ func (s *Server) ConnectProcess(
case *pb.ConnectProcessRequest_Tag:
tag = sel.Tag
default:
return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("pid or tag is required"))
return apperr.ToConnect(apperr.InvalidRequest.Msg("A pid or tag is required."))
}
events, err := s.mgr.ConnectProcess(ctx, msg.SandboxId, pid, tag)
if err != nil {
if errors.Is(err, sandbox.ErrNotFound) {
return connect.NewError(connect.CodeNotFound, err)
}
return connect.NewError(connect.CodeInternal, fmt.Errorf("connect process: %w", err))
return mapSandboxError(fmt.Errorf("connect process: %w", err))
}
for ev := range events {

View File

@ -276,7 +276,7 @@ func execUser(
) (BuildLogEntry, bool) {
username := st.Key
// Create user if not exists, with home directory and bash shell.
// Grant passwordless sudo access (E2B convention).
// Grant passwordless sudo access (matches the wrenn-user default in base images).
// Uses printf %s to avoid shell injection in the sudoers line.
script := fmt.Sprintf(
"id %s >/dev/null 2>&1 || (adduser --disabled-password --gecos '' --shell /bin/bash %s && printf '%%s ALL=(ALL) NOPASSWD:ALL\\n' %s >> /etc/sudoers)",

View File

@ -1 +0,0 @@
package snapshot

View File

@ -1 +0,0 @@
package snapshot

150
pkg/apperr/apperr.go Normal file
View File

@ -0,0 +1,150 @@
// Package apperr is Wrenn's application error domain — the single source of
// truth for error codes, HTTP statuses, client-safe messages, and retryability.
//
// Every error that can reach a client is declared once as a Def in
// catalog.go. Code that fails wraps its cause with a Def
// (Def.Wrap, Def.Msg, ...), and the HTTP layer resolves any error to a
// wire-safe *Error with From. The cause chain is preserved for logs but never
// serialized to clients.
package apperr
import (
"errors"
"fmt"
"maps"
"net/http"
)
// Error is a resolved application error. Code, Status, Message, Retryable and
// Details are client-safe; the wrapped cause is for logs only.
type Error struct {
Code string
Status int
Message string
Retryable bool
Details map[string]any
cause error
}
func (e *Error) Error() string {
if e.cause != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.cause)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *Error) Unwrap() error { return e.cause }
// Is makes errors.Is(err, SomeDef.New()) and cross-instance comparisons match
// on Code, so two Errors with the same code compare equal regardless of
// message, details, or cause.
func (e *Error) Is(target error) bool {
var t *Error
if errors.As(target, &t) {
return t.Code == e.Code
}
return false
}
// With returns a copy of the error with an extra client-visible detail.
// Details must be safe to show to API consumers.
func (e *Error) With(key string, value any) *Error {
c := *e
c.Details = maps.Clone(c.Details)
if c.Details == nil {
c.Details = map[string]any{}
}
c.Details[key] = value
return &c
}
// Def is a catalog entry: an error code with its fixed HTTP status,
// default client-safe message, and retryability. Defs are declared in
// catalog.go and instantiated at failure sites.
type Def struct {
Code string
Status int
Retryable bool
Message string
}
// New returns an Error with the Def's default message.
func (d Def) New() *Error {
return &Error{Code: d.Code, Status: d.Status, Message: d.Message, Retryable: d.Retryable}
}
// Msg returns an Error with a custom client-safe message.
func (d Def) Msg(message string) *Error {
e := d.New()
e.Message = message
return e
}
// Msgf returns an Error with a formatted client-safe message.
// The arguments become part of the client response — never pass raw
// internal error values; use Wrap for those.
func (d Def) Msgf(format string, args ...any) *Error {
return d.Msg(fmt.Sprintf(format, args...))
}
// Wrap returns an Error carrying cause for logs, with the Def's default
// client-safe message. The cause is never serialized to clients.
func (d Def) Wrap(cause error) *Error {
e := d.New()
e.cause = cause
return e
}
// WrapMsg is Wrap with a custom client-safe message.
func (d Def) WrapMsg(cause error, message string) *Error {
e := d.Wrap(cause)
e.Message = message
return e
}
// Is reports whether err resolves to this Def's code.
func (d Def) Is(err error) bool {
var e *Error
return errors.As(err, &e) && e.Code == d.Code
}
// From resolves any error to a client-safe *Error.
//
// - *Error anywhere in the chain: returned as-is.
// - context deadline/cancellation: Timeout — matched on the cause chain
// (errors.Is) before the Connect branch, so a transport error that wraps a
// live context deadline still surfaces as a retryable timeout rather than
// being flattened to internal_error.
// - Connect RPC errors: resolved via the attached ErrorInfo detail when the
// far side provided one, otherwise Internal (see fromConnect).
// - anything else: Internal — the cause is preserved for logging, the
// client sees only the generic message.
func From(err error) *Error {
if err == nil {
return nil
}
var e *Error
if errors.As(err, &e) {
return e
}
if isContextTimeout(err) {
return Timeout.Wrap(err)
}
if ce := asConnectError(err); ce != nil {
return fromConnect(ce)
}
return Internal.Wrap(err)
}
// Cause returns the wrapped internal cause, or nil.
func (e *Error) Cause() error { return e.cause }
// HTTPStatus returns the error's HTTP status, defaulting to 500 for
// zero values so a misconstructed Error can never turn into a 200.
func (e *Error) HTTPStatus() int {
if e.Status == 0 {
return http.StatusInternalServerError
}
return e.Status
}

117
pkg/apperr/apperr_test.go Normal file
View File

@ -0,0 +1,117 @@
package apperr
import (
"context"
"errors"
"fmt"
"net/http"
"testing"
)
func TestDefConstructors(t *testing.T) {
d := Def{Code: "test_failed", Status: http.StatusTeapot, Retryable: true, Message: "default message"}
tests := []struct {
name string
err *Error
wantMessage string
wantCause bool
}{
{"New", d.New(), "default message", false},
{"Msg", d.Msg("custom"), "custom", false},
{"Msgf", d.Msgf("custom %d", 42), "custom 42", false},
{"Wrap", d.Wrap(errors.New("boom")), "default message", true},
{"WrapMsg", d.WrapMsg(errors.New("boom"), "custom"), "custom", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err.Code != "test_failed" || tt.err.Status != http.StatusTeapot || !tt.err.Retryable {
t.Errorf("def fields not carried: %+v", tt.err)
}
if tt.err.Message != tt.wantMessage {
t.Errorf("Message = %q, want %q", tt.err.Message, tt.wantMessage)
}
if (tt.err.Cause() != nil) != tt.wantCause {
t.Errorf("Cause() = %v, wantCause %v", tt.err.Cause(), tt.wantCause)
}
})
}
}
func TestErrorsIsMatchesOnCode(t *testing.T) {
d := Def{Code: "thing_missing", Status: 404, Message: "not found"}
other := Def{Code: "other_code", Status: 404, Message: "not found"}
wrapped := fmt.Errorf("outer: %w", d.Msg("different message"))
if !errors.Is(wrapped, d.New()) {
t.Error("errors.Is should match same code through wrapping")
}
if errors.Is(wrapped, other.New()) {
t.Error("errors.Is should not match different code")
}
if !d.Is(wrapped) {
t.Error("Def.Is should match same code through wrapping")
}
if other.Is(wrapped) {
t.Error("Def.Is should not match different code")
}
}
func TestWithDoesNotMutateOriginal(t *testing.T) {
d := Def{Code: "x", Status: 400, Message: "m"}
base := d.New().With("a", 1)
derived := base.With("b", 2)
if len(base.Details) != 1 {
t.Errorf("base details mutated: %v", base.Details)
}
if derived.Details["a"] != 1 || derived.Details["b"] != 2 {
t.Errorf("derived details wrong: %v", derived.Details)
}
}
func TestFrom(t *testing.T) {
d := Def{Code: "known", Status: 404, Message: "known thing missing"}
t.Run("nil", func(t *testing.T) {
if From(nil) != nil {
t.Error("From(nil) should be nil")
}
})
t.Run("passthrough", func(t *testing.T) {
e := d.Wrap(errors.New("cause"))
got := From(fmt.Errorf("outer: %w", e))
if got.Code != "known" {
t.Errorf("Code = %q, want known", got.Code)
}
})
t.Run("context deadline", func(t *testing.T) {
got := From(fmt.Errorf("op: %w", context.DeadlineExceeded))
if got.Code != Timeout.Code {
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
}
})
t.Run("unknown becomes internal", func(t *testing.T) {
cause := errors.New("db exploded at /var/lib/secret")
got := From(cause)
if got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
if got.Message != Internal.Message {
t.Errorf("internal error must not leak cause; Message = %q", got.Message)
}
if !errors.Is(got, cause) {
t.Error("cause must be preserved in chain for logging")
}
})
}
func TestHTTPStatusDefaultsTo500(t *testing.T) {
e := &Error{Code: "misconstructed"}
if e.HTTPStatus() != http.StatusInternalServerError {
t.Errorf("HTTPStatus = %d, want 500", e.HTTPStatus())
}
}

229
pkg/apperr/catalog.go Normal file
View File

@ -0,0 +1,229 @@
package apperr
import "net/http"
// registry maps code → Def so errors received over RPC (see connect.go) and
// codes from extensions resolve back to their catalog entry.
var registry = map[string]Def{}
func register(d Def) Def {
registry[d.Code] = d
return d
}
// Generic codes. Prefer a domain-specific Def when the client can act on the
// distinction; use these with Msg for one-off cases.
var (
Internal = register(Def{
Code: "internal_error",
Status: http.StatusInternalServerError,
Message: "Something went wrong on our end. Try again; if it keeps failing, contact support with the request ID.",
})
InvalidRequest = register(Def{
Code: "invalid_request",
Status: http.StatusBadRequest,
Message: "The request is invalid.",
})
ValidationFailed = register(Def{
Code: "validation_failed",
Status: http.StatusBadRequest,
Message: "One or more fields are invalid.",
})
Unauthorized = register(Def{
Code: "unauthorized",
Status: http.StatusUnauthorized,
Message: "Authentication required.",
})
Forbidden = register(Def{
Code: "forbidden",
Status: http.StatusForbidden,
Message: "You don't have permission to do that.",
})
NotFound = register(Def{
Code: "not_found",
Status: http.StatusNotFound,
Message: "The requested resource was not found.",
})
Conflict = register(Def{
Code: "conflict",
Status: http.StatusConflict,
Message: "The request conflicts with the current state of the resource.",
})
PayloadTooLarge = register(Def{
Code: "payload_too_large",
Status: http.StatusRequestEntityTooLarge,
Message: "The request payload is too large.",
})
RateLimited = register(Def{
Code: "rate_limited",
Status: http.StatusTooManyRequests,
Retryable: true,
Message: "Too many requests. Slow down and try again.",
})
Timeout = register(Def{
Code: "timeout",
Status: http.StatusGatewayTimeout,
Retryable: true,
Message: "The operation timed out. Try again.",
})
Unavailable = register(Def{
Code: "service_unavailable",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "The service is temporarily unavailable. Try again shortly.",
})
NotImplemented = register(Def{
Code: "not_implemented",
Status: http.StatusNotImplemented,
Message: "This operation is not supported.",
})
)
// Auth and account codes.
var (
AuthInvalidCredentials = register(Def{
Code: "auth_invalid_credentials",
Status: http.StatusUnauthorized,
Message: "Invalid email or password.",
})
AuthSessionRequired = register(Def{
Code: "auth_session_required",
Status: http.StatusUnauthorized,
Message: "A valid session or API key is required.",
})
AuthInvalidAPIKey = register(Def{
Code: "auth_invalid_api_key",
Status: http.StatusUnauthorized,
Message: "Invalid API key.",
})
AuthCSRF = register(Def{
Code: "auth_csrf_failed",
Status: http.StatusForbidden,
Message: "CSRF token missing or invalid. Refresh the page and try again.",
})
AuthAccountNotActivated = register(Def{
Code: "auth_account_not_activated",
Status: http.StatusForbidden,
Message: "Check your email and activate your account before signing in.",
})
AuthAccountDisabled = register(Def{
Code: "auth_account_disabled",
Status: http.StatusForbidden,
Message: "Your account has been deactivated — contact your administrator to regain access.",
})
AuthEmailTaken = register(Def{
Code: "auth_email_taken",
Status: http.StatusConflict,
Message: "An account with this email already exists.",
})
AuthSignupCooldown = register(Def{
Code: "auth_signup_cooldown",
Status: http.StatusConflict,
Message: "A signup for this email is already pending. Check your inbox or try again later.",
})
AuthTokenInvalid = register(Def{
Code: "auth_token_invalid",
Status: http.StatusBadRequest,
Message: "This link is invalid or has expired.",
})
AuthAlreadyActivated = register(Def{
Code: "auth_already_activated",
Status: http.StatusConflict,
Message: "This account has already been activated.",
})
)
// Sandbox lifecycle and infrastructure codes.
var (
SandboxNotFound = register(Def{
Code: "sandbox_not_found",
Status: http.StatusNotFound,
Message: "Sandbox not found.",
})
SandboxNotRunning = register(Def{
Code: "sandbox_not_running",
Status: http.StatusConflict,
Message: "Sandbox is not running.",
})
SandboxNotPaused = register(Def{
Code: "sandbox_not_paused",
Status: http.StatusConflict,
Message: "Sandbox is not paused.",
})
SandboxUnresponsive = register(Def{
Code: "sandbox_unresponsive",
Status: http.StatusBadGateway,
Retryable: true,
Message: "The sandbox is not responding. Try again shortly.",
})
HostUnreachable = register(Def{
Code: "host_unreachable",
Status: http.StatusBadGateway,
Retryable: true,
Message: "Could not reach the sandbox's host. Try again shortly.",
})
HostDraining = register(Def{
Code: "host_draining",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "The host is shutting down and not accepting new work. Try again shortly.",
})
CapacityUnavailable = register(Def{
Code: "capacity_unavailable",
Status: http.StatusServiceUnavailable,
Retryable: true,
Message: "No hosts currently have capacity for this request. Try again shortly.",
})
TemplateNotFound = register(Def{
Code: "template_not_found",
Status: http.StatusNotFound,
Message: "Template not found.",
})
TemplateProtected = register(Def{
Code: "template_protected",
Status: http.StatusForbidden,
Message: "System templates cannot be modified or deleted.",
})
SnapshotNotFound = register(Def{
Code: "snapshot_not_found",
Status: http.StatusNotFound,
Message: "Snapshot not found.",
})
BuildNotFound = register(Def{
Code: "build_not_found",
Status: http.StatusNotFound,
Message: "Build not found.",
})
HostNotFound = register(Def{
Code: "host_not_found",
Status: http.StatusNotFound,
Message: "Host not found.",
})
HostHasActiveSandboxes = register(Def{
Code: "has_active_sandboxes",
Status: http.StatusConflict,
Message: "This host has active sandboxes. Pass ?force=true to destroy them and delete the host.",
})
TeamNotFound = register(Def{
Code: "team_not_found",
Status: http.StatusNotFound,
Message: "Team not found.",
})
UserNotFound = register(Def{
Code: "user_not_found",
Status: http.StatusNotFound,
Message: "User not found.",
})
)
// Lookup returns the catalog Def for a code, or false if unregistered.
// Extensions can add their own codes with Register.
func Lookup(code string) (Def, bool) {
d, ok := registry[code]
return d, ok
}
// Register adds a Def to the catalog registry so errors carrying its code
// resolve to it when received over RPC. Intended for extensions; OSS codes
// are registered at package init. Registering an existing code overwrites it.
func Register(d Def) Def { return register(d) }

119
pkg/apperr/connect.go Normal file
View File

@ -0,0 +1,119 @@
package apperr
import (
"errors"
"fmt"
"net/http"
"connectrpc.com/connect"
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
)
// ToConnect converts any error to a *connect.Error carrying an ErrorInfo
// detail. The RPC error message keeps the full cause chain (the CP↔agent
// channel is internal, and the receiver logs it); the ErrorInfo detail holds
// only the client-safe fields, which the receiving side re-surfaces via From.
func ToConnect(err error) *connect.Error {
e := From(err)
if e == nil {
// Defensive: a nil error reaching here is a caller bug. Return a
// generic internal error rather than panicking on the nil deref.
e = Internal.New()
}
ce := connect.NewError(connectCodeFor(e.HTTPStatus()), errors.New(e.Error()))
if detail, derr := connect.NewErrorDetail(errorInfo(e)); derr == nil {
ce.AddDetail(detail)
}
return ce
}
func errorInfo(e *Error) *pb.ErrorInfo {
info := &pb.ErrorInfo{
Code: e.Code,
Message: e.Message,
Retryable: e.Retryable,
HttpStatus: int32(e.HTTPStatus()),
}
if len(e.Details) > 0 {
info.Details = make(map[string]string, len(e.Details))
for k, v := range e.Details {
info.Details[k] = fmt.Sprint(v)
}
}
return info
}
func asConnectError(err error) *connect.Error {
var ce *connect.Error
if errors.As(err, &ce) {
return ce
}
return nil
}
// fromConnect resolves a Connect RPC error to an *Error. Every wrenn service
// attaches an ErrorInfo detail via ToConnect, so an error carrying one is
// reconstructed losslessly. An error WITHOUT a detail did not originate from a
// wrenn handler — it is a transport-level failure synthesized by the Connect
// framework itself (a dropped connection, a client-side deadline) — so it
// resolves to internal_error, with the raw RPC error hidden from the client
// but kept in the chain for logs.
func fromConnect(ce *connect.Error) *Error {
for _, d := range ce.Details() {
msg, err := d.Value()
if err != nil {
continue
}
if info, ok := msg.(*pb.ErrorInfo); ok {
e := &Error{
Code: info.Code,
Status: int(info.HttpStatus),
Message: info.Message,
Retryable: info.Retryable,
cause: ce,
}
if e.Status == 0 {
if d, ok := Lookup(info.Code); ok {
e.Status = d.Status
} else {
e.Status = http.StatusInternalServerError
}
}
if len(info.Details) > 0 {
e.Details = make(map[string]any, len(info.Details))
for k, v := range info.Details {
e.Details[k] = v
}
}
return e
}
}
return Internal.Wrap(ce)
}
// connectCodeFor maps an HTTP status to the Connect code used on the wire.
func connectCodeFor(status int) connect.Code {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return connect.CodeInvalidArgument
case http.StatusUnauthorized:
return connect.CodeUnauthenticated
case http.StatusForbidden:
return connect.CodePermissionDenied
case http.StatusNotFound:
return connect.CodeNotFound
case http.StatusConflict:
return connect.CodeFailedPrecondition
case http.StatusTooManyRequests:
return connect.CodeResourceExhausted
case http.StatusNotImplemented:
return connect.CodeUnimplemented
case http.StatusBadGateway, http.StatusServiceUnavailable:
return connect.CodeUnavailable
case http.StatusGatewayTimeout:
return connect.CodeDeadlineExceeded
default:
return connect.CodeInternal
}
}

166
pkg/apperr/connect_test.go Normal file
View File

@ -0,0 +1,166 @@
package apperr
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"connectrpc.com/connect"
)
// WriteHTTP(nil) must not panic on the From(nil)==nil deref; it should emit a
// generic 500 envelope rather than a bare 200 or a crash.
func TestWriteHTTPNil(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
WriteHTTP(rec, req, nil)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
var body wireEnvelope
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Error.Code != Internal.Code {
t.Errorf("code = %q, want %q", body.Error.Code, Internal.Code)
}
}
func TestConnectRoundTrip(t *testing.T) {
orig := SandboxNotRunning.
Wrap(errors.New("boxes map miss")).
With("status", "paused")
ce := ToConnect(orig)
if ce.Code() != connect.CodeFailedPrecondition {
t.Errorf("connect code = %v, want FailedPrecondition", ce.Code())
}
got := From(fmt.Errorf("rpc CreateSandbox: %w", ce))
if got.Code != orig.Code {
t.Errorf("Code = %q, want %q", got.Code, orig.Code)
}
if got.Status != orig.Status {
t.Errorf("Status = %d, want %d", got.Status, orig.Status)
}
if got.Message != orig.Message {
t.Errorf("Message = %q, want %q", got.Message, orig.Message)
}
if got.Retryable != orig.Retryable {
t.Errorf("Retryable = %v, want %v", got.Retryable, orig.Retryable)
}
if got.Details["status"] != "paused" {
t.Errorf("Details = %v, want status=paused", got.Details)
}
// The internal cause must not surface in client-safe fields.
if got.Message == orig.Error() {
t.Error("cause chain leaked into Message")
}
}
// Bad-gateway (502) catalog defs must map to a retryable Connect code, not the
// generic Internal, so observability and consumers keying off the raw wire code
// see a transient infrastructure failure rather than an internal-invariant bug.
func TestToConnectBadGateway(t *testing.T) {
for _, def := range []Def{SandboxUnresponsive, HostUnreachable} {
t.Run(def.Code, func(t *testing.T) {
ce := ToConnect(def.Wrap(errors.New("envd dial: connection refused")))
if ce.Code() != connect.CodeUnavailable {
t.Errorf("connect code = %v, want Unavailable", ce.Code())
}
// The ErrorInfo detail keeps the round trip lossless.
got := From(fmt.Errorf("rpc: %w", ce))
if got.Code != def.Code {
t.Errorf("Code = %q, want %q", got.Code, def.Code)
}
if got.Status != http.StatusBadGateway {
t.Errorf("Status = %d, want 502", got.Status)
}
if !got.Retryable {
t.Error("Retryable = false, want true")
}
})
}
}
// Without an ErrorInfo detail, a Connect error did not come from a wrenn
// handler — it is a transport-level failure synthesized by the framework — so
// it resolves to internal_error regardless of its Connect code. The raw cause
// is hidden from the client but stays in the chain for logs. (The old
// guess-from-code mapping is gone.)
func TestFromConnectWithoutDetail(t *testing.T) {
codes := []connect.Code{
connect.CodeUnavailable,
connect.CodeNotFound,
connect.CodeInvalidArgument,
connect.CodeFailedPrecondition,
connect.CodePermissionDenied,
connect.CodeUnauthenticated,
connect.CodeResourceExhausted,
connect.CodeUnimplemented,
connect.CodeDeadlineExceeded,
connect.CodeInternal,
connect.CodeUnknown,
}
for _, code := range codes {
t.Run(code.String(), func(t *testing.T) {
ce := connect.NewError(code, errors.New("open /var/lib/wrenn/secret: boom"))
got := From(ce)
if got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
if got.Message != Internal.Message {
t.Errorf("raw cause leaked to client message: %q", got.Message)
}
if !errors.Is(got, error(ce)) {
t.Error("connect error must remain in chain for logging")
}
})
}
}
// A Connect error that genuinely wraps a context deadline still resolves to
// Timeout — matched on the cause chain, not guessed from the Connect code —
// so a real client-side timeout stays a retryable 504.
func TestFromContextDeadlineViaConnect(t *testing.T) {
ce := connect.NewError(connect.CodeDeadlineExceeded, context.DeadlineExceeded)
got := From(ce)
if got.Code != Timeout.Code {
t.Errorf("Code = %q, want %q", got.Code, Timeout.Code)
}
if !got.Retryable {
t.Error("Retryable = false, want true")
}
}
// ToConnect(nil) must not panic on the From(nil)==nil deref; a nil error
// reaching here is a caller bug that should degrade to a generic internal
// error, not crash the RPC handler.
func TestToConnectNil(t *testing.T) {
ce := ToConnect(nil)
if ce.Code() != connect.CodeInternal {
t.Errorf("code = %v, want Internal", ce.Code())
}
if got := From(ce); got.Code != Internal.Code {
t.Errorf("Code = %q, want %q", got.Code, Internal.Code)
}
}
func TestToConnectFromPlainError(t *testing.T) {
ce := ToConnect(errors.New("dmsetup create failed: device busy"))
if ce.Code() != connect.CodeInternal {
t.Errorf("code = %v, want Internal", ce.Code())
}
got := From(ce)
if got.Code != Internal.Code || got.Status != http.StatusInternalServerError {
t.Errorf("got %q/%d, want internal_error/500", got.Code, got.Status)
}
if got.Message != Internal.Message {
t.Errorf("internal cause leaked to client message: %q", got.Message)
}
}

14
pkg/apperr/context.go Normal file
View File

@ -0,0 +1,14 @@
package apperr
import (
"context"
"errors"
)
// isContextTimeout reports whether err is a context deadline or cancellation.
// Both resolve to Timeout: a cancelled request context reaches error mapping
// only when the work was cut short, which the client should treat the same
// as a timeout.
func isContextTimeout(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}

98
pkg/apperr/http.go Normal file
View File

@ -0,0 +1,98 @@
package apperr
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
)
type ctxKey struct{}
// RequestIDHeader is the response header carrying the request ID.
const RequestIDHeader = "X-Request-ID"
// Middleware assigns each request an ID (req_ + 16 hex), stores it in the
// context, and echoes it in the X-Request-ID response header. Error responses
// include it so users can quote an ID that log lines are keyed by.
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := newRequestID()
w.Header().Set(RequestIDHeader, id)
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "req_" + hex.EncodeToString(b)
}
// WithRequestID returns a context carrying the request ID.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// RequestID returns the request ID from the context, or "" if none.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
type wireError struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
Retryable bool `json:"retryable"`
Details map[string]any `json:"details,omitempty"`
}
type wireEnvelope struct {
Error wireError `json:"error"`
}
// WriteHTTP resolves err via From and writes the error envelope. The full
// cause chain is logged keyed by request ID — 5xx at ERROR, wrapped 4xx at
// WARN — and never serialized to the client.
func WriteHTTP(w http.ResponseWriter, r *http.Request, err error) {
e := From(err)
if e == nil {
// Defensive: a nil error reaching here is a caller bug. Never emit a
// bare 200 — surface a generic 500 so the client still gets an envelope.
e = Internal.New()
}
reqID := RequestID(r.Context())
if e.HTTPStatus() >= 500 {
slog.Error("request failed",
"request_id", reqID,
"code", e.Code,
"status", e.HTTPStatus(),
"method", r.Method,
"path", r.URL.Path,
"error", e.Error(),
)
} else if e.Cause() != nil {
slog.Warn("request rejected",
"request_id", reqID,
"code", e.Code,
"status", e.HTTPStatus(),
"method", r.Method,
"path", r.URL.Path,
"error", e.Error(),
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(e.HTTPStatus())
_ = json.NewEncoder(w).Encode(wireEnvelope{Error: wireError{
Code: e.Code,
Message: e.Message,
RequestID: reqID,
Retryable: e.Retryable,
Details: e.Details,
}})
}

View File

@ -308,15 +308,18 @@ func (l *AuditLogger) LogSandboxDestroyWithReason(ctx context.Context, ac auth.A
})
}
// LogSandboxCreateSystem records a system-derived create outcome (e.g. the
// LogSandboxCreateSystem records a system-derived create failure (e.g. the
// reconciler inferring a failed first-boot after the grace period expired).
// reason is added to metadata; err controls outcome.
// It writes an "error" action — not a "create" one — so the entry reads as a
// capsule error rather than a system-attributed creation; the user-attributed
// create row is written separately by the request handler. reason and the
// "create" phase are added to metadata; err controls outcome.
func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": "create"}
l.Log(ctx, Entry{
TeamID: teamID, ActorType: "system",
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
Action: "create", Scope: "team", Status: auditStatusFor(err, "info"),
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
Metadata: mergeMeta(meta, err),
})
l.publish(ctx, events.Event{
@ -331,14 +334,16 @@ func (l *AuditLogger) LogSandboxCreateSystem(ctx context.Context, teamID, sandbo
})
}
// LogSandboxResumeSystem records a system-derived resume outcome (typically
// reconciler-inferred error after the grace period).
// LogSandboxResumeSystem records a system-derived resume failure (typically
// reconciler-inferred error after the grace period). Like LogSandboxCreateSystem
// it writes an "error" action so the entry reads as a capsule error rather than
// a system-attributed resume; reason and the "resume" phase go to metadata.
func (l *AuditLogger) LogSandboxResumeSystem(ctx context.Context, teamID, sandboxID pgtype.UUID, reason string, err error) {
meta := map[string]any{"reason": reason}
meta := map[string]any{"reason": reason, "phase": "resume"}
l.Log(ctx, Entry{
TeamID: teamID, ActorType: "system",
ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID),
Action: "resume", Scope: "team", Status: auditStatusFor(err, "info"),
Action: "error", Scope: "team", Status: auditStatusFor(err, "info"),
Metadata: mergeMeta(meta, err),
})
l.publish(ctx, events.Event{

View File

@ -7,19 +7,19 @@ package middleware
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/netutil"
)
// Cookie + header names. Exported so extensions and frontends can reference
@ -30,21 +30,6 @@ const (
CSRFHeaderName = "X-CSRF-Token"
)
type errorBody struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{Error: errorDetail{Code: code, Message: message}})
}
// IsSecure reports whether the inbound request should produce Secure cookies.
// Honors X-Forwarded-Proto for deployments behind TLS-terminating proxies.
func IsSecure(r *http.Request) bool {
@ -174,7 +159,7 @@ func RequireSession(svc *session.Service, queries *db.Queries) func(http.Handler
sess, err := ResolveSession(r.Context(), queries, svc, r)
if err != nil {
ClearCookies(w, IsSecure(r))
writeError(w, http.StatusUnauthorized, "unauthorized", "valid session required")
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.WrapMsg(err, "A valid session is required."))
return
}
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
@ -192,13 +177,13 @@ func RequireSessionOrAPIKey(svc *session.Service, queries *db.Queries) func(http
next.ServeHTTP(w, r.WithContext(ctx))
return
}
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key")
apperr.WriteHTTP(w, r, apperr.AuthInvalidAPIKey.New())
return
}
sess, err := ResolveSession(r.Context(), queries, svc, r)
if err != nil {
ClearCookies(w, IsSecure(r))
writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header or session cookie required")
apperr.WriteHTTP(w, r, apperr.AuthSessionRequired.Wrap(err))
return
}
ctx := auth.WithAuthContext(r.Context(), AuthContextFromSession(sess))
@ -216,12 +201,12 @@ func RequireAdmin(queries *db.Queries) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ac, ok := auth.FromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication required")
apperr.WriteHTTP(w, r, apperr.Unauthorized.New())
return
}
user, err := queries.GetUserByID(r.Context(), ac.UserID)
if err != nil || !user.IsAdmin {
writeError(w, http.StatusForbidden, "forbidden", "admin access required")
apperr.WriteHTTP(w, r, apperr.Forbidden.WrapMsg(err, "Admin access required."))
return
}
next.ServeHTTP(w, r)
@ -248,7 +233,7 @@ func RequireCSRF() func(http.Handler) http.Handler {
header := r.Header.Get(CSRFHeaderName)
if err != nil || cookie.Value == "" || header == "" ||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) != 1 {
writeError(w, http.StatusForbidden, "csrf_failed", "missing or invalid CSRF token")
apperr.WriteHTTP(w, r, apperr.AuthCSRF.New())
return
}
next.ServeHTTP(w, r)
@ -279,7 +264,7 @@ func IssueSession(
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), clientIP(r))
sess, err := svc.Create(ctx, userID, teamID, user.Email, user.Name, role, user.IsAdmin, r.UserAgent(), netutil.ClientIP(r))
if err != nil {
return nil, err
}
@ -287,12 +272,3 @@ func IssueSession(
return sess, nil
}
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}

View File

@ -12,14 +12,16 @@ import (
// Deliver sends a notification to a single provider with the given config.
// For webhooks it uses HMAC-signed HTTP POST; for all others it uses shoutrrr.
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event) error {
// allowPrivate relaxes the SSRF guard on the webhook client for self-hosted
// deployments that deliver to internal endpoints.
func Deliver(ctx context.Context, provider string, config map[string]string, e events.Event, allowPrivate bool) error {
payload, err := json.Marshal(e)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
if provider == "webhook" {
wh := NewWebhookDelivery()
wh := NewWebhookDelivery(allowPrivate)
return wh.Deliver(ctx, config["url"], config["secret"], payload)
}

View File

@ -18,22 +18,31 @@ const (
consumerName = "cp-0"
)
// maxConcurrentRetries bounds the number of in-flight delivery retries. Without
// it, an endpoint outage during an event burst would spawn one goroutine per
// failed delivery — each holding decrypted secrets in memory for up to the full
// retry window.
const maxConcurrentRetries = 64
// Dispatcher consumes events from the Redis stream and delivers them
// to matching notification channels.
type Dispatcher struct {
rdb *redis.Client
db *db.Queries
encKey [32]byte
webhook *WebhookDelivery
allowPrivate bool
retrySem chan struct{}
}
// NewDispatcher constructs an event dispatcher.
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte) *Dispatcher {
// NewDispatcher constructs an event dispatcher. allowPrivate relaxes the SSRF
// guard on outbound webhook deliveries.
func NewDispatcher(rdb *redis.Client, queries *db.Queries, encKey [32]byte, allowPrivate bool) *Dispatcher {
return &Dispatcher{
rdb: rdb,
db: queries,
encKey: encKey,
webhook: NewWebhookDelivery(),
allowPrivate: allowPrivate,
retrySem: make(chan struct{}, maxConcurrentRetries),
}
}
@ -138,10 +147,21 @@ func (d *Dispatcher) dispatch(ctx context.Context, ch db.Channel, e events.Event
chID := id.FormatChannelID(ch.ID)
if err := Deliver(ctx, ch.Provider, config, e); err != nil {
if err := Deliver(ctx, ch.Provider, config, e, d.allowPrivate); err != nil {
slog.Warn("channels: delivery failed, scheduling retries",
"channel_id", chID, "provider", ch.Provider, "error", err)
go d.retryDeliver(ctx, ch.Provider, config, e, chID)
// Bound concurrent retries: acquire a slot or drop (with a log) rather
// than spawning unbounded goroutines during a sustained outage.
select {
case d.retrySem <- struct{}{}:
go func() {
defer func() { <-d.retrySem }()
d.retryDeliver(ctx, ch.Provider, config, e, chID)
}()
default:
slog.Warn("channels: retry queue saturated, dropping retry",
"channel_id", chID, "provider", ch.Provider)
}
}
}
@ -153,7 +173,7 @@ func (d *Dispatcher) retryDeliver(ctx context.Context, provider string, config m
case <-time.After(delay):
}
if err := Deliver(ctx, provider, config, e); err != nil {
if err := Deliver(ctx, provider, config, e, d.allowPrivate); err != nil {
slog.Warn("channels: retry delivery failed",
"channel_id", chID, "provider", provider,
"attempt", i+2, "error", err)

View File

@ -13,6 +13,7 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/events"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -55,6 +56,8 @@ func init() {
type Service struct {
DB *db.Queries
EncKey [32]byte
// AllowPrivateTargets disables the SSRF guard on channel destination URLs.
AllowPrivateTargets bool
}
// CreateParams holds the parameters for creating a channel.
@ -81,25 +84,30 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
p.Name = clean
if !validProviders[p.Provider] {
return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", p.Provider)
return CreateResult{}, apperr.ValidationFailed.Msgf("Unsupported provider %q.", p.Provider).With("field", "provider")
}
if len(p.Events) == 0 {
return CreateResult{}, fmt.Errorf("invalid: at least one event type is required")
return CreateResult{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
}
for _, et := range p.Events {
if !validEvents[et] {
return CreateResult{}, fmt.Errorf("invalid: unknown event type %q", et)
return CreateResult{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
}
}
// Validate required config fields.
for _, field := range requiredFields[p.Provider] {
if p.Config[field] == "" {
return CreateResult{}, fmt.Errorf("invalid: %s is required for %s", field, p.Provider)
return CreateResult{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, p.Provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
if err := validateConfigURLs(p.Config, s.AllowPrivateTargets); err != nil {
return CreateResult{}, err
}
// For webhooks, auto-generate secret if not provided.
var plaintextSecret string
if p.Provider == "webhook" {
@ -138,7 +146,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, err
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return CreateResult{}, fmt.Errorf("conflict: channel name %q already exists", p.Name)
return CreateResult{}, apperr.Conflict.Msgf("A channel named %q already exists.", p.Name)
}
return CreateResult{}, fmt.Errorf("insert channel: %w", err)
}
@ -165,11 +173,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
name = clean
if len(eventTypes) == 0 {
return db.Channel{}, fmt.Errorf("invalid: at least one event type is required")
return db.Channel{}, apperr.ValidationFailed.Msg("At least one event type is required.").With("field", "events")
}
for _, et := range eventTypes {
if !validEvents[et] {
return db.Channel{}, fmt.Errorf("invalid: unknown event type %q", et)
return db.Channel{}, apperr.ValidationFailed.Msgf("Unknown event type %q.", et).With("field", "events")
}
}
@ -181,11 +189,11 @@ func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, nam
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return db.Channel{}, fmt.Errorf("conflict: channel name %q already exists", name)
return db.Channel{}, apperr.Conflict.Msgf("A channel named %q already exists.", name)
}
return db.Channel{}, fmt.Errorf("update channel: %w", err)
}
@ -198,7 +206,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
ch, err := s.DB.GetChannelByTeam(ctx, db.GetChannelByTeamParams{ID: channelID, TeamID: teamID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
return db.Channel{}, fmt.Errorf("get channel: %w", err)
}
@ -206,10 +214,15 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
// Validate required config fields for this provider.
for _, field := range requiredFields[ch.Provider] {
if config[field] == "" {
return db.Channel{}, fmt.Errorf("invalid: %s is required for %s", field, ch.Provider)
return db.Channel{}, apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, ch.Provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
return db.Channel{}, err
}
// For webhooks, auto-generate secret if not provided.
if ch.Provider == "webhook" && config["secret"] == "" {
config["secret"] = generateSecret()
@ -237,7 +250,7 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return db.Channel{}, fmt.Errorf("channel not found")
return db.Channel{}, apperr.NotFound.Msg("Channel not found.")
}
return db.Channel{}, fmt.Errorf("update channel config: %w", err)
}
@ -247,15 +260,21 @@ func (s *Service) RotateConfig(ctx context.Context, channelID, teamID pgtype.UUI
// Test validates config and sends a test notification without persisting anything.
func (s *Service) Test(ctx context.Context, provider string, config map[string]string) error {
if !validProviders[provider] {
return fmt.Errorf("invalid: unsupported provider %q", provider)
return apperr.ValidationFailed.Msgf("Unsupported provider %q.", provider).With("field", "provider")
}
for _, field := range requiredFields[provider] {
if config[field] == "" {
return fmt.Errorf("invalid: %s is required for %s", field, provider)
return apperr.ValidationFailed.Msgf("The %s field is required for %s.", field, provider).With("field", field)
}
}
// Reject destination URLs pointing at internal/private addresses (SSRF).
// Test is the sharpest oracle: it returns the delivery result synchronously.
if err := validateConfigURLs(config, s.AllowPrivateTargets); err != nil {
return err
}
// For webhooks, auto-generate a temporary secret if not provided.
if provider == "webhook" && config["secret"] == "" {
config["secret"] = generateSecret()
@ -269,7 +288,7 @@ func (s *Service) Test(ctx context.Context, provider string, config map[string]s
Resource: events.Resource{ID: "test", Type: "channel"},
}
return Deliver(ctx, provider, config, testEvent)
return Deliver(ctx, provider, config, testEvent, s.AllowPrivateTargets)
}
// Delete removes a channel by ID, scoped to the given team.
@ -284,7 +303,7 @@ func cleanName(name string) (string, error) {
name = strings.ToLower(name)
name = strings.ReplaceAll(name, " ", "-")
if err := validate.SafeName(name); err != nil {
return "", fmt.Errorf("invalid: %w", err)
return "", apperr.ValidationFailed.WrapMsg(err, "Invalid channel name.").With("field", "name")
}
return name, nil
}

112
pkg/channels/ssrf.go Normal file
View File

@ -0,0 +1,112 @@
package channels
import (
"context"
"fmt"
"net"
"net/url"
"syscall"
"time"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
)
// urlConfigFields are the provider config keys that carry a caller-supplied
// destination URL. Only these can drive an outbound HTTP connection to an
// attacker-chosen host, so they are the SSRF-relevant fields to validate.
var urlConfigFields = []string{"url", "webhook_url", "homeserver_url"}
// validateConfigURLs rejects any destination URL whose host resolves to a
// non-public address. When allowPrivate is true (self-hosted deployments that
// legitimately deliver to internal endpoints) the check is skipped entirely.
func validateConfigURLs(config map[string]string, allowPrivate bool) error {
if allowPrivate {
return nil
}
for _, field := range urlConfigFields {
raw := config[field]
if raw == "" {
continue
}
if err := validateTargetURL(raw); err != nil {
return apperr.ValidationFailed.
Msgf("The %s field must be a reachable public URL: %v", field, err).
With("field", field)
}
}
return nil
}
// validateTargetURL parses raw as an http(s) URL and rejects it if the host
// resolves to any non-public address. Resolution happens here (parse time) for
// fast user feedback; the dial-time guard (dialControl) re-checks at connect
// time to defeat DNS rebinding.
func validateTargetURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme %q is not allowed", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("missing host")
}
// A literal IP needs no DNS lookup.
if ip := net.ParseIP(host); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("host is a non-public address")
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("could not resolve host")
}
for _, ip := range ips {
if isBlockedIP(ip) {
return fmt.Errorf("host resolves to a non-public address")
}
}
return nil
}
// isBlockedIP reports whether ip belongs to a range that must never be a
// notification target: loopback, RFC1918/RFC4193 private, link-local
// (which includes the 169.254.169.254 cloud metadata endpoint), multicast,
// and the unspecified address.
func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// dialContext returns a DialContext that blocks connections to non-public
// addresses. The Control hook runs after DNS resolution with the concrete
// resolved IP, so a host that passed parse-time validation but rebinds to a
// private address is still rejected at connect time.
func dialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second}
if !allowPrivate {
dialer.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip != nil && isBlockedIP(ip) {
return fmt.Errorf("dial to non-public address %s blocked", ip)
}
return nil
}
}
return dialer.DialContext
}

View File

@ -18,14 +18,18 @@ type WebhookDelivery struct {
client *http.Client
}
// NewWebhookDelivery constructs a webhook delivery client.
func NewWebhookDelivery() *WebhookDelivery {
// NewWebhookDelivery constructs a webhook delivery client. When allowPrivate is
// false the client refuses to connect to non-public addresses (SSRF guard).
func NewWebhookDelivery(allowPrivate bool) *WebhookDelivery {
return &WebhookDelivery{
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DialContext: dialContext(allowPrivate),
},
},
}
}

View File

@ -30,6 +30,12 @@ type Config struct {
EncryptionKeyHex string // WRENN_ENCRYPTION_KEY raw hex string (for validation)
EncryptionKey [32]byte // parsed 32-byte key
// ChannelsAllowPrivateTargets permits notification channels (webhooks etc.)
// to point at private/loopback/link-local addresses. Off by default to
// prevent SSRF against internal services; enable only on self-hosted
// deployments that legitimately deliver to internal endpoints.
ChannelsAllowPrivateTargets bool // WRENN_CHANNELS_ALLOW_PRIVATE
// SMTP — transactional email. All fields optional; omitting SMTPHost disables email.
SMTPHost string // SMTP_HOST
SMTPPort int // SMTP_PORT (default 587)
@ -47,7 +53,7 @@ func Load() Config {
cfg := Config{
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"),
RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"),
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"),
ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":9725"),
JWTSecret: os.Getenv("JWT_SECRET"),
WrennDir: envOrDefault("WRENN_DIR", "/var/lib/wrenn"),
@ -61,6 +67,8 @@ func Load() Config {
EncryptionKeyHex: os.Getenv("WRENN_ENCRYPTION_KEY"),
ChannelsAllowPrivateTargets: envOrDefaultBool("WRENN_CHANNELS_ALLOW_PRIVATE", false),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: envOrDefaultInt("SMTP_PORT", 587),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
@ -96,3 +104,15 @@ func envOrDefaultInt(key string, def int) int {
}
return n
}
func envOrDefaultBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}

View File

@ -150,8 +150,8 @@ func Run(opts ...Option) {
os.Exit(1)
}
channelPub := channels.NewPublisher(rdb)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey)
channelSvc := &channels.Service{DB: queries, EncKey: cfg.EncryptionKey, AllowPrivateTargets: cfg.ChannelsAllowPrivateTargets}
channelDispatcher := channels.NewDispatcher(rdb, queries, cfg.EncryptionKey, cfg.ChannelsAllowPrivateTargets)
// Shared audit logger with event publishing.
al := audit.NewWithPublisher(queries, channelPub)

View File

@ -103,7 +103,7 @@ func (r *LoopRegistry) ReleaseAll() {
// SnapshotDevice holds the state for a single dm-snapshot device.
type SnapshotDevice struct {
Name string // dm device name, e.g., "wrenn-sb-a1b2c3d4"
Name string // dm device name, e.g., "wrenn-cl-a1b2c3d4"
DevicePath string // /dev/mapper/<Name>
CowPath string // path to the sparse CoW file
CowLoopDev string // loop device for the CoW file

View File

@ -258,11 +258,12 @@ func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-
}
// WriteFile writes content to a file inside the sandbox via envd's REST endpoint.
// envd expects PUT /files?path=...&username=root with the raw file content as the body.
// envd expects PUT /files?path=... with the raw file content as the body. No
// username is sent, so envd resolves the sandbox default user — the same user
// process execution runs as.
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
"path": {path},
"username": {"root"},
}.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(content))
@ -288,11 +289,11 @@ func (c *Client) WriteFile(ctx context.Context, path string, content []byte) err
}
// ReadFile reads a file from inside the sandbox via envd's REST endpoint.
// envd expects GET /files?path=...&username=root.
// envd expects GET /files?path=... — no username is sent, so envd resolves the
// sandbox default user, matching process execution.
func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
"path": {path},
"username": {"root"},
}.Encode())
slog.Debug("envd read file", "url", u, "path", path)

View File

@ -1 +0,0 @@
package lifecycle

21
pkg/netutil/netutil.go Normal file
View File

@ -0,0 +1,21 @@
// Package netutil holds small HTTP/networking helpers shared across the
// control plane. It lives in pkg/ so both internal/api and the auth
// middleware (and cloud extensions) can import a single implementation.
package netutil
import (
"net/http"
"strings"
)
// ClientIP returns the request's apparent client IP, honoring
// X-Forwarded-For when behind a reverse proxy.
func ClientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}

View File

@ -1,6 +1,7 @@
package network
import (
"errors"
"fmt"
"os"
"path/filepath"
@ -8,6 +9,10 @@ import (
"sync"
)
// ErrNoFreeSlots is returned when every network slot index is claimed —
// the host cannot take another sandbox until one is released.
var ErrNoFreeSlots = errors.New("no free network slots")
// SlotAllocator manages network slot indices for sandboxes.
// Each sandbox needs a unique slot index for its network addressing.
//
@ -58,7 +63,7 @@ func (a *SlotAllocator) Allocate() (int, error) {
a.inUse[i] = true
return i, nil
}
return 0, fmt.Errorf("no free network slots")
return 0, ErrNoFreeSlots
}
// Release frees a slot index for reuse.

View File

@ -38,6 +38,12 @@ var (
ErrNotPaused = errors.New("sandbox not paused")
// ErrInvalidRange is returned when a metrics range parameter is invalid.
ErrInvalidRange = errors.New("invalid range")
// ErrTemplateNotFound is returned when a template's rootfs image does not
// exist on this host.
ErrTemplateNotFound = errors.New("template rootfs not found")
// ErrEnvdNotReady is returned when the VM booted (or resumed) but envd did
// not become healthy within the readiness budget.
ErrEnvdNotReady = errors.New("envd not ready")
)
// MinTimeoutSec is the minimum inactivity TTL accepted by Create/Resume.
@ -386,7 +392,7 @@ func (m *Manager) Create(
// Resolve base rootfs image.
baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
if _, err := os.Stat(baseRootfs); err != nil {
return nil, 0, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
return nil, 0, fmt.Errorf("%w: base rootfs missing at %s: %w", ErrTemplateNotFound, baseRootfs, err)
}
// Acquire shared read-only loop device for the base image.
@ -475,7 +481,7 @@ func (m *Manager) Create(
if err := client.WaitUntilReady(waitCtx); err != nil {
res.rollback()
return nil, 0, fmt.Errorf("wait for envd: %w", err)
return nil, 0, fmt.Errorf("%w: %w", ErrEnvdNotReady, err)
}
// Fetch envd version (best-effort).

View File

@ -81,7 +81,7 @@ func (m *Manager) launchRestoredVM(ctx context.Context, vmCfg vm.VMConfig, hostI
defer waitCancel()
if err := client.WaitUntilReady(waitCtx); err != nil {
_ = m.vm.Destroy(context.Background(), vmCfg.SandboxID)
return nil, fmt.Errorf("wait envd: %w", err)
return nil, fmt.Errorf("%w after resume: %w", ErrEnvdNotReady, err)
}
// Best-effort balloon deflate. Free-page reporting drains pages while the

View File

@ -29,7 +29,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
wrennDir := t.TempDir()
want := &runningState{
Version: runningStateVersion,
ID: "sb-cafe0123",
ID: "cl-cafe0123",
TeamID: "00000000-0000-0000-0000-000000000000",
TemplateID: "00000000-0000-0000-0000-000000000001",
VCPUs: 2,
@ -37,12 +37,12 @@ func TestRunningStateRoundtrip(t *testing.T) {
TimeoutSec: 300,
SlotIndex: 7,
BaseImagePath: "/var/lib/wrenn/images/teams/x/y/rootfs.ext4",
CowPath: "/var/lib/wrenn/sandboxes/sb-cafe0123/rootfs.cow",
DMName: "wrenn-sb-cafe0123",
SandboxDir: "/tmp/ch-vm-sb-cafe0123",
CowPath: "/var/lib/wrenn/sandboxes/cl-cafe0123/rootfs.cow",
DMName: "wrenn-cl-cafe0123",
SandboxDir: "/tmp/ch-vm-cl-cafe0123",
CHPID: 4242,
CHSocket: "/tmp/ch-sb-cafe0123.sock",
SandboxDirOverride: "/tmp/ch-vm-sb-original",
CHSocket: "/tmp/ch-cl-cafe0123.sock",
SandboxDirOverride: "/tmp/ch-vm-cl-original",
LazyRestore: true,
CreatedAt: time.Now().Truncate(0),
Metadata: map[string]string{"kernel_version": "6.1.102"},
@ -65,7 +65,7 @@ func TestRunningStateRoundtrip(t *testing.T) {
func TestRunningStateVersionMismatch(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion + 1, ID: "sb-deadbeef", SlotIndex: 1}
st := &runningState{Version: runningStateVersion + 1, ID: "cl-deadbeef", SlotIndex: 1}
writeTestRunningState(t, wrennDir, st)
if _, err := readRunningState(wrennDir, st.ID); err == nil {
@ -75,9 +75,9 @@ func TestRunningStateVersionMismatch(t *testing.T) {
func TestRunningStateIDMismatch(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion, ID: "sb-other", SlotIndex: 1}
st := &runningState{Version: runningStateVersion, ID: "cl-other", SlotIndex: 1}
// Write the file under a directory whose name does not match st.ID.
dir := layout.SandboxDir(wrennDir, "sb-dirname")
dir := layout.SandboxDir(wrennDir, "cl-dirname")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
@ -86,20 +86,20 @@ func TestRunningStateIDMismatch(t *testing.T) {
t.Fatal(err)
}
if _, err := readRunningState(wrennDir, "sb-dirname"); err == nil {
if _, err := readRunningState(wrennDir, "cl-dirname"); err == nil {
t.Fatal("readRunningState should reject id mismatch")
}
}
func TestRunningStateMissingFile(t *testing.T) {
if _, err := readRunningState(t.TempDir(), "sb-none"); !os.IsNotExist(err) {
if _, err := readRunningState(t.TempDir(), "cl-none"); !os.IsNotExist(err) {
t.Fatalf("want os.IsNotExist error, got %v", err)
}
}
func TestDeleteRunningState(t *testing.T) {
wrennDir := t.TempDir()
st := &runningState{Version: runningStateVersion, ID: "sb-gone", SlotIndex: 1}
st := &runningState{Version: runningStateVersion, ID: "cl-gone", SlotIndex: 1}
writeTestRunningState(t, wrennDir, st)
deleteRunningState(wrennDir, st.ID)

View File

@ -6,6 +6,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
)
@ -95,9 +96,9 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
if len(candidates) == 0 {
if isByoc {
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
}
return db.Host{}, fmt.Errorf("no online platform hosts available")
return db.Host{}, apperr.CapacityUnavailable.New()
}
// Phase 2: admission control + selection — pick the highest-scoring host
@ -119,7 +120,10 @@ func (s *LeastLoadedScheduler) SelectHost(ctx context.Context, teamID pgtype.UUI
}
if best == -1 {
return db.Host{}, fmt.Errorf("no host has sufficient resources: need %d MB memory, %d MB disk", memoryMb, diskSizeMb)
return db.Host{}, apperr.CapacityUnavailable.
Msg("No host has enough free resources for this sandbox. Try again shortly or request a smaller size.").
With("memory_mb", memoryMb).
With("disk_mb", diskSizeMb)
}
return candidates[best].host, nil

View File

@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
)
@ -66,9 +67,9 @@ func (s *RoundRobinScheduler) SelectHost(ctx context.Context, teamID pgtype.UUID
if len(eligible) == 0 {
if isByoc {
return db.Host{}, fmt.Errorf("no online BYOC hosts available for team")
return db.Host{}, apperr.CapacityUnavailable.Msg("None of your team's hosts are online. Bring a host online and try again.")
}
return db.Host{}, fmt.Errorf("no online platform hosts available")
return db.Host{}, apperr.CapacityUnavailable.New()
}
idx := s.counter.Add(1) - 1

View File

@ -1 +0,0 @@
package scheduler

View File

@ -1 +0,0 @@
package scheduler

View File

@ -3,12 +3,15 @@ package service
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/validate"
)
// APIKeyService provides API key operations shared between the REST API and the dashboard.
@ -25,8 +28,13 @@ type APIKeyCreateResult struct {
// Create generates a new API key for the given team.
func (s *APIKeyService) Create(ctx context.Context, teamID, userID pgtype.UUID, name string) (APIKeyCreateResult, error) {
name = strings.TrimSpace(name)
if name == "" {
name = "Unnamed API Key"
} else if err := validate.DisplayName(name); err != nil {
return APIKeyCreateResult{}, apperr.ValidationFailed.
WrapMsg(err, "API key name may only contain letters, numbers, spaces, and . _ - (max 100 characters).").
With("field", "name")
}
plaintext, hash, err := auth.GenerateAPIKey()

View File

@ -15,6 +15,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/recipe"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -188,11 +189,11 @@ func (s *BuildService) List(ctx context.Context) ([]db.TemplateBuild, error) {
func (s *BuildService) Cancel(ctx context.Context, buildID pgtype.UUID) error {
build, err := s.DB.GetTemplateBuild(ctx, buildID)
if err != nil {
return fmt.Errorf("get build: %w", err)
return apperr.BuildNotFound.Wrap(err)
}
switch build.Status {
case "success", "failed", "cancelled":
return fmt.Errorf("build is already %s", build.Status)
return apperr.Conflict.Msgf("The build is already %s.", build.Status)
}
// Mark cancelled in DB first. This handles both pending builds (which haven't
@ -441,13 +442,14 @@ func (s *BuildService) provisionBuildSandbox(
) (buildAgentClient, string, map[string]string, error) {
host, err := s.Scheduler.SelectHost(ctx, id.PlatformTeamID, false, build.MemoryMb, 5120)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("no host available: %v", err))
// Persist the client-safe message — this string surfaces in the build UI.
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
agent, err := s.Pool.GetForHost(host)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("agent client error: %v", err))
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
@ -459,7 +461,7 @@ func (s *BuildService) provisionBuildSandbox(
// platform-owned rows, so resolve the path from the DB record.
baseTmpl, err := s.DB.GetPlatformTemplateByName(ctx, build.BaseTemplate)
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found: %v", build.BaseTemplate, err))
s.failBuild(ctx, buildID, fmt.Sprintf("base template %q not found", build.BaseTemplate))
return nil, "", nil, err
}
baseTeamID := baseTmpl.TeamID
@ -476,7 +478,7 @@ func (s *BuildService) provisionBuildSandbox(
DiskSizeMb: 0,
}))
if err != nil {
s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err))
s.failBuild(ctx, buildID, apperr.From(err).Message)
return nil, "", nil, err
}
sandboxMetadata := resp.Msg.Metadata

View File

@ -10,6 +10,7 @@ import (
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
@ -76,7 +77,7 @@ func clampTimeout(timeoutSec int32) int32 {
func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.UUID) (hostagentClient, db.Sandbox, error) {
sb, err := s.DB.GetSandbox(ctx, sandboxID)
if err != nil {
return nil, db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return nil, db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
agent, err := s.agentForHost(ctx, sb.HostID)
if err != nil {
@ -90,7 +91,7 @@ func (s *SandboxService) agentForSandbox(ctx context.Context, sandboxID pgtype.U
func (s *SandboxService) agentForHost(ctx context.Context, hostID pgtype.UUID) (hostagentClient, error) {
host, err := s.DB.GetHost(ctx, hostID)
if err != nil {
return nil, fmt.Errorf("host not found: %w", err)
return nil, apperr.HostNotFound.Wrap(err)
}
agent, err := s.Pool.GetForHost(host)
if err != nil {
@ -126,10 +127,10 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
p.Template = "minimal-ubuntu"
}
if err := validate.SafeName(p.Template); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid template name: %v.", err)
}
if err := validate.Metadata(p.Metadata); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
return db.Sandbox{}, apperr.ValidationFailed.Msgf("Invalid metadata: %v.", err)
}
if p.VCPUs <= 0 {
p.VCPUs = 2
@ -144,7 +145,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
// query also matches platform templates for any team).
tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("template %q not found: %w", p.Template, err)
return db.Sandbox{}, apperr.TemplateNotFound.WrapMsg(err, fmt.Sprintf("Template %q not found.", p.Template))
}
templateTeamID := tmpl.TeamID
templateID := tmpl.ID
@ -159,12 +160,12 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
}
if !p.TeamID.Valid {
return db.Sandbox{}, fmt.Errorf("invalid request: team_id is required")
return db.Sandbox{}, apperr.InvalidRequest.Msg("A team_id is required.")
}
team, err := s.DB.GetTeam(ctx, p.TeamID)
if err != nil {
return db.Sandbox{}, fmt.Errorf("team not found: %w", err)
return db.Sandbox{}, apperr.TeamNotFound.Wrap(err)
}
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc, p.MemoryMB, 0)
@ -324,7 +325,7 @@ func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID pgtype.UUID)
func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "paused" {
return sb, nil
@ -333,7 +334,7 @@ func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID pgtype.UUI
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: "running", Status_2: "pausing",
}); err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not in running state (current: %s)", sb.Status)
return db.Sandbox{}, apperr.SandboxNotRunning.Wrap(err).With("status", sb.Status)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -400,7 +401,7 @@ func (s *SandboxService) pauseInBackground(sandboxID pgtype.UUID, sandboxIDStr,
func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UUID) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "running" {
return sb, nil
@ -409,7 +410,7 @@ func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID pgtype.UU
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: "paused", Status_2: "resuming",
}); err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not in paused state (current: %s)", sb.Status)
return db.Sandbox{}, apperr.SandboxNotPaused.Wrap(err).With("status", sb.Status)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -509,10 +510,10 @@ func (s *SandboxService) resumeInBackground(
func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID pgtype.UUID, name string) (db.Sandbox, string, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, "", fmt.Errorf("sandbox not found: %w", err)
return db.Sandbox{}, "", apperr.SandboxNotFound.Wrap(err)
}
if sb.Status != "running" && sb.Status != "paused" {
return db.Sandbox{}, "", fmt.Errorf("sandbox is not running or paused (status: %s)", sb.Status)
return db.Sandbox{}, "", apperr.Conflict.Msg("Sandbox must be running or paused to snapshot.").With("status", sb.Status)
}
origStatus := sb.Status
@ -520,18 +521,18 @@ func (s *SandboxService) CreateSnapshot(ctx context.Context, sandboxID, teamID p
name = id.NewSnapshotName()
}
if err := validate.SafeName(name); err != nil {
return db.Sandbox{}, "", fmt.Errorf("invalid name: %w", err)
return db.Sandbox{}, "", apperr.ValidationFailed.Msgf("Invalid snapshot name: %v.", err)
}
// Reject duplicate names up front so we don't pause the VM and dump memory
// only to fail on the template insert at the very end.
if _, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: teamID}); err == nil {
return db.Sandbox{}, "", fmt.Errorf("conflict: a snapshot named %q already exists", name)
return db.Sandbox{}, "", apperr.Conflict.Msgf("A snapshot named %q already exists.", name)
}
if _, err := s.DB.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
ID: sandboxID, Status: origStatus, Status_2: "snapshotting",
}); err != nil {
return db.Sandbox{}, "", fmt.Errorf("sandbox not in %s state (current: %s)", origStatus, sb.Status)
return db.Sandbox{}, "", apperr.Conflict.WrapMsg(err, fmt.Sprintf("Sandbox is no longer in %s state.", origStatus)).With("status", origStatus)
}
agent, err := s.agentForHost(ctx, sb.HostID)
@ -639,7 +640,7 @@ func (s *SandboxService) publishStateChanged(ctx context.Context, sandboxIDStr,
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return fmt.Errorf("sandbox not found: %w", err)
return apperr.SandboxNotFound.Wrap(err)
}
if sb.Status == "stopped" || sb.Status == "error" {
return nil
@ -745,7 +746,7 @@ func (s *SandboxService) persistMetricPoints(ctx context.Context, sandboxID pgty
func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgtype.UUID) (int64, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return 0, fmt.Errorf("sandbox not found: %w", err)
return 0, apperr.SandboxNotFound.Wrap(err)
}
// For running or paused sandboxes, try the agent for live disk usage.
@ -776,10 +777,10 @@ func (s *SandboxService) GetDiskUsage(ctx context.Context, sandboxID, teamID pgt
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID pgtype.UUID) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return fmt.Errorf("sandbox not found: %w", err)
return apperr.SandboxNotFound.Wrap(err)
}
if sb.Status != "running" {
return fmt.Errorf("sandbox is not running (status: %s)", sb.Status)
return apperr.SandboxNotRunning.New().With("status", sb.Status)
}
agent, _, err := s.agentForSandbox(ctx, sandboxID)

View File

@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
@ -56,7 +57,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
})
if err != nil {
if err == pgx.ErrNoRows {
return "", fmt.Errorf("forbidden: not a member of this team")
return "", apperr.Forbidden.Msg("You are not a member of this team.")
}
return "", fmt.Errorf("get membership: %w", err)
}
@ -66,7 +67,7 @@ func (s *TeamService) callerRole(ctx context.Context, teamID, callerUserID pgtyp
// requireAdmin returns an error if the caller is not an admin or owner.
func requireAdmin(role string) error {
if role != "owner" && role != "admin" {
return fmt.Errorf("forbidden: admin or owner role required")
return apperr.Forbidden.Msg("Admin or owner role required.")
}
return nil
}
@ -76,12 +77,12 @@ func (s *TeamService) GetTeam(ctx context.Context, teamID pgtype.UUID) (db.Team,
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
if err == pgx.ErrNoRows {
return db.Team{}, fmt.Errorf("team not found")
return db.Team{}, apperr.TeamNotFound.New()
}
return db.Team{}, fmt.Errorf("get team: %w", err)
}
if team.DeletedAt.Valid {
return db.Team{}, fmt.Errorf("team not found")
return db.Team{}, apperr.TeamNotFound.New()
}
return team, nil
}
@ -105,7 +106,7 @@ func (s *TeamService) ListTeamsForUser(ctx context.Context, userID pgtype.UUID)
// CreateTeam creates a new team owned by the given user.
func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, name string) (TeamWithRole, error) {
if !teamNameRE.MatchString(name) {
return TeamWithRole{}, fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
return TeamWithRole{}, apperr.ValidationFailed.Msg("Team name must be 1128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
}
tx, err := s.Pool.Begin(ctx)
@ -145,7 +146,7 @@ func (s *TeamService) CreateTeam(ctx context.Context, ownerUserID pgtype.UUID, n
// RenameTeam updates the team name. Caller must be admin or owner (verified from DB).
func (s *TeamService) RenameTeam(ctx context.Context, teamID, callerUserID pgtype.UUID, newName string) error {
if !teamNameRE.MatchString(newName) {
return fmt.Errorf("invalid team name: must be 1-128 characters, A-Z a-z 0-9 space _")
return apperr.ValidationFailed.Msg("Team name must be 1128 characters: letters, numbers, spaces, and underscores.").With("field", "name")
}
role, err := s.callerRole(ctx, teamID, callerUserID)
@ -171,7 +172,7 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
return err
}
if role != "owner" {
return fmt.Errorf("forbidden: only the owner can delete a team")
return apperr.Forbidden.Msg("Only the owner can delete a team.")
}
return s.deleteTeamCore(ctx, teamID)
@ -323,7 +324,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
target, err := s.DB.GetUserByEmail(ctx, email)
if err != nil {
if err == pgx.ErrNoRows {
return MemberInfo{}, fmt.Errorf("user not found: no account with that email")
return MemberInfo{}, apperr.UserNotFound.Msg("No account exists with that email.")
}
return MemberInfo{}, fmt.Errorf("look up user: %w", err)
}
@ -334,7 +335,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID pgtype
TeamID: teamID,
})
if memberCheckErr == nil {
return MemberInfo{}, fmt.Errorf("invalid: user is already a member of this team")
return MemberInfo{}, apperr.Conflict.Msg("This user is already a member of the team.")
} else if memberCheckErr != pgx.ErrNoRows {
return MemberInfo{}, fmt.Errorf("check membership: %w", memberCheckErr)
}
@ -368,13 +369,13 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
})
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("not found: user is not a member of this team")
return apperr.NotFound.Msg("This user is not a member of the team.")
}
return fmt.Errorf("get target membership: %w", err)
}
if targetMembership.Role == "owner" {
return fmt.Errorf("forbidden: the owner cannot be removed from the team")
return apperr.Forbidden.Msg("The owner cannot be removed from the team.")
}
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
@ -411,7 +412,7 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
// Valid target roles: "admin", "member".
func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID, targetUserID pgtype.UUID, newRole string) error {
if newRole != "admin" && newRole != "member" {
return fmt.Errorf("invalid: role must be admin or member")
return apperr.ValidationFailed.Msg("Role must be either admin or member.").With("field", "role")
}
callerRole, err := s.callerRole(ctx, teamID, callerUserID)
@ -428,13 +429,13 @@ func (s *TeamService) UpdateMemberRole(ctx context.Context, teamID, callerUserID
})
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("not found: user is not a member of this team")
return apperr.NotFound.Msg("This user is not a member of the team.")
}
return fmt.Errorf("get target membership: %w", err)
}
if targetMembership.Role == "owner" {
return fmt.Errorf("forbidden: the owner's role cannot be changed")
return apperr.Forbidden.Msg("The owner's role cannot be changed.")
}
if err := s.DB.UpdateMemberRole(ctx, db.UpdateMemberRoleParams{
@ -455,7 +456,7 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
return err
}
if role == "owner" {
return fmt.Errorf("forbidden: the owner cannot leave the team; delete the team instead")
return apperr.Forbidden.Msg("The owner cannot leave the team; delete the team instead.")
}
if err := s.DB.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{
@ -473,13 +474,13 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
func (s *TeamService) SetBYOC(ctx context.Context, teamID pgtype.UUID, enabled bool) error {
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
return apperr.TeamNotFound.Wrap(err)
}
if team.DeletedAt.Valid {
return fmt.Errorf("team not found")
return apperr.TeamNotFound.New()
}
if !enabled {
return fmt.Errorf("invalid request: BYOC cannot be disabled once enabled")
return apperr.Conflict.Msg("BYOC cannot be disabled once enabled.")
}
if team.IsByoc {
// Already enabled — idempotent, no-op.
@ -563,10 +564,10 @@ func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID
func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) error {
team, err := s.DB.GetTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
return apperr.TeamNotFound.Wrap(err)
}
if team.DeletedAt.Valid {
return fmt.Errorf("team not found")
return apperr.TeamNotFound.New()
}
return s.deleteTeamCore(ctx, teamID)

View File

@ -0,0 +1,78 @@
package validate
import "testing"
func TestDisplayName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"simple", "John", false},
{"with-space", "John Doe", false},
{"with-dot", "John D.", false},
{"with-dash", "Anne-Marie", false},
{"with-underscore", "cool_name", false},
{"numbers", "user123", false},
{"max-length", repeat("a", 100), false},
{"empty", "", true},
{"too-long", repeat("a", 101), true},
{"angle-open", "John<", true},
{"angle-close", "John>", true},
{"ampersand", "A&B", true},
{"double-quote", "he\"llo", true},
{"single-quote", "O'Brien", true},
{"xss-img", "<img src=x onerror=alert(1)>", true},
{"xss-script", "<script>alert(1)</script>", true},
{"at-sign", "a@b", true},
{"unicode-letter", "José", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := DisplayName(tt.input); (err != nil) != tt.wantErr {
t.Errorf("DisplayName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestSanitizeDisplayName(t *testing.T) {
tests := []struct {
name string
input string
fallback string
want string
}{
{"clean", "John Doe", "user", "John Doe"},
{"strip-html", "<img src=x>Bob", "user", "img srcxBob"},
{"strip-angles-only", "a<b>c", "user", "abc"},
{"strip-unicode", "José", "user", "Jos"},
{"all-stripped", "<>&\"'", "user", "user"},
{"empty", "", "fallback", "fallback"},
{"trims", " spaced ", "user", "spaced"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.want {
t.Errorf("SanitizeDisplayName(%q, %q) = %q, want %q", tt.input, tt.fallback, got, tt.want)
}
// A sanitized non-fallback result must itself be a valid DisplayName.
if got := SanitizeDisplayName(tt.input, tt.fallback); got != tt.fallback {
if err := DisplayName(got); err != nil {
t.Errorf("SanitizeDisplayName(%q) = %q is not a valid DisplayName: %v", tt.input, got, err)
}
}
})
}
}
func repeat(s string, n int) string {
out := make([]byte, 0, len(s)*n)
for range n {
out = append(out, s...)
}
return string(out)
}

27
pkg/validate/email.go Normal file
View File

@ -0,0 +1,27 @@
package validate
import (
"fmt"
"regexp"
)
// emailRe is a pragmatic email check: a local part of common address
// characters, an @, a dotted domain, and a 2+ character TLD. It excludes
// spaces and the HTML-significant characters < > & " ' so a stored email is
// safe to render without escaping.
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Email validates that email is a well-formed address within length limits.
// Callers should TrimSpace (and typically ToLower) first.
func Email(email string) error {
if email == "" {
return fmt.Errorf("email must not be empty")
}
if len(email) > 254 {
return fmt.Errorf("email is too long (max 254 characters)")
}
if !emailRe.MatchString(email) {
return fmt.Errorf("email is not a valid address")
}
return nil
}

View File

@ -0,0 +1,35 @@
package validate
import "testing"
func TestEmail(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"simple", "user@example.com", false},
{"subdomain", "user@mail.example.com", false},
{"plus-tag", "user+tag@example.com", false},
{"dots", "first.last@example.co.uk", false},
{"digits", "user123@ex4mple.io", false},
{"empty", "", true},
{"no-at", "userexample.com", true},
{"no-domain", "user@", true},
{"no-tld", "user@example", true},
{"space", "user @example.com", true},
{"angle-bracket", "user@<script>.com", true},
{"xss-payload", "a@b.com<img src=x onerror=alert(1)>", true},
{"quote", "us\"er@example.com", true},
{"leading-space", " user@example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Email(tt.input); (err != nil) != tt.wantErr {
t.Errorf("Email(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

View File

@ -3,12 +3,50 @@ package validate
import (
"fmt"
"regexp"
"strings"
)
// nameRe matches safe path component names: alphanumeric start, then
// alphanumeric, dash, underscore, or dot. Max 64 characters.
var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// displayNameRe matches user-facing display names (user names, API key names):
// letters, digits, spaces, dots, underscores, hyphens. It deliberately
// excludes the HTML-significant characters < > & " ' so these values are safe
// to render without escaping. Max 100 characters.
var displayNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,100}$`)
// disallowedNameChars matches any character not permitted in a display name.
var disallowedNameChars = regexp.MustCompile(`[^A-Za-z0-9 ._-]`)
// DisplayName validates a user-supplied display name. Callers should TrimSpace
// first. It rejects empty names, names over 100 characters, and any character
// outside the alphanumeric + space + . _ - allowlist.
func DisplayName(name string) error {
if name == "" {
return fmt.Errorf("name must not be empty")
}
if !displayNameRe.MatchString(name) {
return fmt.Errorf("name may only contain letters, numbers, spaces, and . _ - (max 100 characters)")
}
return nil
}
// SanitizeDisplayName coerces an externally-sourced name (e.g. from an OAuth
// provider) into the DisplayName allowlist by dropping disallowed characters.
// Such names cannot be rejected interactively, so they are cleaned instead.
// Returns fallback if nothing usable remains.
func SanitizeDisplayName(name, fallback string) string {
cleaned := strings.TrimSpace(disallowedNameChars.ReplaceAllString(name, ""))
if len(cleaned) > 100 {
cleaned = strings.TrimSpace(cleaned[:100])
}
if cleaned == "" {
return fallback
}
return cleaned
}
// SafeName checks that name is safe for use as a single filesystem path
// component. It rejects empty strings, path separators, ".." sequences,
// leading dots, and anything outside the alphanumeric+dash+underscore+dot

View File

@ -27,7 +27,7 @@ type VMConfig struct {
KernelPath string
// RootfsPath is the path to the rootfs block device for this sandbox.
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-sb-a1b2c3d4).
// Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-cl-a1b2c3d4).
RootfsPath string
// VCPUs is the number of virtual CPUs to allocate (default: 1).

View File

@ -4256,6 +4256,85 @@ func (*ConnectProcessResponse_Data) isConnectProcessResponse_Event() {}
func (*ConnectProcessResponse_End) isConnectProcessResponse_Event() {}
// ErrorInfo travels as a Connect error detail so the control plane can
// surface precise, client-safe errors instead of guessing from Connect
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
type ErrorInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // apperr catalog code, e.g. "sandbox_not_running"
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // client-safe message
Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"`
HttpStatus int32 `protobuf:"varint,4,opt,name=http_status,json=httpStatus,proto3" json:"http_status,omitempty"`
Details map[string]string `protobuf:"bytes,5,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ErrorInfo) Reset() {
*x = ErrorInfo{}
mi := &file_hostagent_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ErrorInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ErrorInfo) ProtoMessage() {}
func (x *ErrorInfo) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ErrorInfo.ProtoReflect.Descriptor instead.
func (*ErrorInfo) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{71}
}
func (x *ErrorInfo) GetCode() string {
if x != nil {
return x.Code
}
return ""
}
func (x *ErrorInfo) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ErrorInfo) GetRetryable() bool {
if x != nil {
return x.Retryable
}
return false
}
func (x *ErrorInfo) GetHttpStatus() int32 {
if x != nil {
return x.HttpStatus
}
return 0
}
func (x *ErrorInfo) GetDetails() map[string]string {
if x != nil {
return x.Details
}
return nil
}
var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" +
@ -4594,7 +4673,17 @@ const file_hostagent_proto_rawDesc = "" +
"\x05start\x18\x01 \x01(\v2\x1d.hostagent.v1.ExecStreamStartH\x00R\x05start\x122\n" +
"\x04data\x18\x02 \x01(\v2\x1c.hostagent.v1.ExecStreamDataH\x00R\x04data\x12/\n" +
"\x03end\x18\x03 \x01(\v2\x1b.hostagent.v1.ExecStreamEndH\x00R\x03endB\a\n" +
"\x05event2\xb3\x14\n" +
"\x05event\"\xf4\x01\n" +
"\tErrorInfo\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" +
"\tretryable\x18\x03 \x01(\bR\tretryable\x12\x1f\n" +
"\vhttp_status\x18\x04 \x01(\x05R\n" +
"httpStatus\x12>\n" +
"\adetails\x18\x05 \x03(\v2$.hostagent.v1.ErrorInfo.DetailsEntryR\adetails\x1a:\n" +
"\fDetailsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xb3\x14\n" +
"\x10HostAgentService\x12X\n" +
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
@ -4642,7 +4731,7 @@ func file_hostagent_proto_rawDescGZIP() []byte {
return file_hostagent_proto_rawDescData
}
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 79)
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 81)
var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
@ -4715,23 +4804,25 @@ var file_hostagent_proto_goTypes = []any{
(*KillProcessResponse)(nil), // 68: hostagent.v1.KillProcessResponse
(*ConnectProcessRequest)(nil), // 69: hostagent.v1.ConnectProcessRequest
(*ConnectProcessResponse)(nil), // 70: hostagent.v1.ConnectProcessResponse
nil, // 71: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
nil, // 72: hostagent.v1.CreateSandboxResponse.MetadataEntry
nil, // 73: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
nil, // 74: hostagent.v1.ResumeSandboxResponse.MetadataEntry
nil, // 75: hostagent.v1.ExecRequest.EnvsEntry
nil, // 76: hostagent.v1.SandboxInfo.MetadataEntry
nil, // 77: hostagent.v1.PtyAttachRequest.EnvsEntry
nil, // 78: hostagent.v1.StartBackgroundRequest.EnvsEntry
(*ErrorInfo)(nil), // 71: hostagent.v1.ErrorInfo
nil, // 72: hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
nil, // 73: hostagent.v1.CreateSandboxResponse.MetadataEntry
nil, // 74: hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
nil, // 75: hostagent.v1.ResumeSandboxResponse.MetadataEntry
nil, // 76: hostagent.v1.ExecRequest.EnvsEntry
nil, // 77: hostagent.v1.SandboxInfo.MetadataEntry
nil, // 78: hostagent.v1.PtyAttachRequest.EnvsEntry
nil, // 79: hostagent.v1.StartBackgroundRequest.EnvsEntry
nil, // 80: hostagent.v1.ErrorInfo.DetailsEntry
}
var file_hostagent_proto_depIdxs = []int32{
71, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
72, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
73, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
74, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
75, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
72, // 0: hostagent.v1.CreateSandboxRequest.default_env:type_name -> hostagent.v1.CreateSandboxRequest.DefaultEnvEntry
73, // 1: hostagent.v1.CreateSandboxResponse.metadata:type_name -> hostagent.v1.CreateSandboxResponse.MetadataEntry
74, // 2: hostagent.v1.ResumeSandboxRequest.default_env:type_name -> hostagent.v1.ResumeSandboxRequest.DefaultEnvEntry
75, // 3: hostagent.v1.ResumeSandboxResponse.metadata:type_name -> hostagent.v1.ResumeSandboxResponse.MetadataEntry
76, // 4: hostagent.v1.ExecRequest.envs:type_name -> hostagent.v1.ExecRequest.EnvsEntry
16, // 5: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
76, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
77, // 6: hostagent.v1.SandboxInfo.metadata:type_name -> hostagent.v1.SandboxInfo.MetadataEntry
23, // 7: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart
24, // 8: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData
25, // 9: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd
@ -4742,80 +4833,81 @@ var file_hostagent_proto_depIdxs = []int32{
42, // 14: hostagent.v1.FlushSandboxMetricsResponse.points_10m:type_name -> hostagent.v1.MetricPoint
42, // 15: hostagent.v1.FlushSandboxMetricsResponse.points_2h:type_name -> hostagent.v1.MetricPoint
42, // 16: hostagent.v1.FlushSandboxMetricsResponse.points_24h:type_name -> hostagent.v1.MetricPoint
77, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
78, // 17: hostagent.v1.PtyAttachRequest.envs:type_name -> hostagent.v1.PtyAttachRequest.EnvsEntry
53, // 18: hostagent.v1.PtyAttachResponse.started:type_name -> hostagent.v1.PtyStarted
54, // 19: hostagent.v1.PtyAttachResponse.output:type_name -> hostagent.v1.PtyOutput
55, // 20: hostagent.v1.PtyAttachResponse.exited:type_name -> hostagent.v1.PtyExited
78, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
79, // 21: hostagent.v1.StartBackgroundRequest.envs:type_name -> hostagent.v1.StartBackgroundRequest.EnvsEntry
65, // 22: hostagent.v1.ListProcessesResponse.processes:type_name -> hostagent.v1.ProcessEntry
23, // 23: hostagent.v1.ConnectProcessResponse.start:type_name -> hostagent.v1.ExecStreamStart
24, // 24: hostagent.v1.ConnectProcessResponse.data:type_name -> hostagent.v1.ExecStreamData
25, // 25: hostagent.v1.ConnectProcessResponse.end:type_name -> hostagent.v1.ExecStreamEnd
0, // 26: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 27: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 28: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 29: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 30: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 31: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 32: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 33: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 34: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 35: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 36: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 37: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 38: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 39: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 40: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 41: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 42: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 43: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 44: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 45: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 46: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
51, // 47: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
56, // 48: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
58, // 49: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
60, // 50: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
62, // 51: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
64, // 52: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
67, // 53: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
69, // 54: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
49, // 55: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
1, // 56: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 57: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 58: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 59: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 60: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 61: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 62: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 63: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 64: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 65: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 66: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 67: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 68: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 69: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 70: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 71: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 72: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 73: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 74: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 75: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 76: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
52, // 77: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
57, // 78: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
59, // 79: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
61, // 80: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
63, // 81: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
66, // 82: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
68, // 83: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
70, // 84: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
50, // 85: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
56, // [56:86] is the sub-list for method output_type
26, // [26:56] is the sub-list for method input_type
26, // [26:26] is the sub-list for extension type_name
26, // [26:26] is the sub-list for extension extendee
0, // [0:26] is the sub-list for field type_name
80, // 26: hostagent.v1.ErrorInfo.details:type_name -> hostagent.v1.ErrorInfo.DetailsEntry
0, // 27: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 28: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 29: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 30: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
12, // 31: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
14, // 32: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
17, // 33: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest
19, // 34: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest
31, // 35: hostagent.v1.HostAgentService.ListDir:input_type -> hostagent.v1.ListDirRequest
34, // 36: hostagent.v1.HostAgentService.MakeDir:input_type -> hostagent.v1.MakeDirRequest
36, // 37: hostagent.v1.HostAgentService.RemovePath:input_type -> hostagent.v1.RemovePathRequest
8, // 38: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest
10, // 39: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest
21, // 40: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest
26, // 41: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest
29, // 42: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest
38, // 43: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest
40, // 44: hostagent.v1.HostAgentService.Terminate:input_type -> hostagent.v1.TerminateRequest
43, // 45: hostagent.v1.HostAgentService.GetSandboxMetrics:input_type -> hostagent.v1.GetSandboxMetricsRequest
45, // 46: hostagent.v1.HostAgentService.FlushSandboxMetrics:input_type -> hostagent.v1.FlushSandboxMetricsRequest
47, // 47: hostagent.v1.HostAgentService.FlattenRootfs:input_type -> hostagent.v1.FlattenRootfsRequest
51, // 48: hostagent.v1.HostAgentService.PtyAttach:input_type -> hostagent.v1.PtyAttachRequest
56, // 49: hostagent.v1.HostAgentService.PtySendInput:input_type -> hostagent.v1.PtySendInputRequest
58, // 50: hostagent.v1.HostAgentService.PtyResize:input_type -> hostagent.v1.PtyResizeRequest
60, // 51: hostagent.v1.HostAgentService.PtyKill:input_type -> hostagent.v1.PtyKillRequest
62, // 52: hostagent.v1.HostAgentService.StartBackground:input_type -> hostagent.v1.StartBackgroundRequest
64, // 53: hostagent.v1.HostAgentService.ListProcesses:input_type -> hostagent.v1.ListProcessesRequest
67, // 54: hostagent.v1.HostAgentService.KillProcess:input_type -> hostagent.v1.KillProcessRequest
69, // 55: hostagent.v1.HostAgentService.ConnectProcess:input_type -> hostagent.v1.ConnectProcessRequest
49, // 56: hostagent.v1.HostAgentService.GetTemplateSize:input_type -> hostagent.v1.GetTemplateSizeRequest
1, // 57: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 58: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 59: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 60: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
13, // 61: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
15, // 62: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
18, // 63: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse
20, // 64: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse
32, // 65: hostagent.v1.HostAgentService.ListDir:output_type -> hostagent.v1.ListDirResponse
35, // 66: hostagent.v1.HostAgentService.MakeDir:output_type -> hostagent.v1.MakeDirResponse
37, // 67: hostagent.v1.HostAgentService.RemovePath:output_type -> hostagent.v1.RemovePathResponse
9, // 68: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse
11, // 69: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse
22, // 70: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse
28, // 71: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse
30, // 72: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse
39, // 73: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse
41, // 74: hostagent.v1.HostAgentService.Terminate:output_type -> hostagent.v1.TerminateResponse
44, // 75: hostagent.v1.HostAgentService.GetSandboxMetrics:output_type -> hostagent.v1.GetSandboxMetricsResponse
46, // 76: hostagent.v1.HostAgentService.FlushSandboxMetrics:output_type -> hostagent.v1.FlushSandboxMetricsResponse
48, // 77: hostagent.v1.HostAgentService.FlattenRootfs:output_type -> hostagent.v1.FlattenRootfsResponse
52, // 78: hostagent.v1.HostAgentService.PtyAttach:output_type -> hostagent.v1.PtyAttachResponse
57, // 79: hostagent.v1.HostAgentService.PtySendInput:output_type -> hostagent.v1.PtySendInputResponse
59, // 80: hostagent.v1.HostAgentService.PtyResize:output_type -> hostagent.v1.PtyResizeResponse
61, // 81: hostagent.v1.HostAgentService.PtyKill:output_type -> hostagent.v1.PtyKillResponse
63, // 82: hostagent.v1.HostAgentService.StartBackground:output_type -> hostagent.v1.StartBackgroundResponse
66, // 83: hostagent.v1.HostAgentService.ListProcesses:output_type -> hostagent.v1.ListProcessesResponse
68, // 84: hostagent.v1.HostAgentService.KillProcess:output_type -> hostagent.v1.KillProcessResponse
70, // 85: hostagent.v1.HostAgentService.ConnectProcess:output_type -> hostagent.v1.ConnectProcessResponse
50, // 86: hostagent.v1.HostAgentService.GetTemplateSize:output_type -> hostagent.v1.GetTemplateSizeResponse
57, // [57:87] is the sub-list for method output_type
27, // [27:57] is the sub-list for method input_type
27, // [27:27] is the sub-list for extension type_name
27, // [27:27] is the sub-list for extension extendee
0, // [0:27] is the sub-list for field type_name
}
func init() { file_hostagent_proto_init() }
@ -4861,7 +4953,7 @@ func file_hostagent_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 79,
NumMessages: 81,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -595,3 +595,14 @@ message ConnectProcessResponse {
ExecStreamEnd end = 3;
}
}
// ErrorInfo travels as a Connect error detail so the control plane can
// surface precise, client-safe errors instead of guessing from Connect
// codes. Fields mirror pkg/apperr.Error; details values are stringified.
message ErrorInfo {
string code = 1; // apperr catalog code, e.g. "sandbox_not_running"
string message = 2; // client-safe message
bool retryable = 3;
int32 http_status = 4;
map<string, string> details = 5;
}