Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
41b63c1
feat(agent): introduce remote deployment agent
Lftobs Aug 13, 2026
cd7599f
feat(orchestrator): add server preparation and
Lftobs Aug 21, 2026
6b16da8
refactor(api): inject dependencies in tests
Lftobs Aug 21, 2026
89d93ad
Merge remote-tracking branch 'origin/feat/multi-servers' into agent-a…
Lftobs Aug 21, 2026
6acb64f
refactor(db): migrate from SQLite to PostgreSQL
Lftobs Aug 24, 2026
22df367
refactore(db): migrate to PostgreSQL
Lftobs Aug 24, 2026
5bcbd13
fix(orchestrator): use HTTP health check instead of TCP for failover
Lftobs Aug 24, 2026
e6800ba
docs: add agent Caddy routes lifecycle documentation
Lftobs Aug 24, 2026
513c94f
feat(api): add domain DNS/TLS status check endpoint
Lftobs Aug 24, 2026
9029b3d
feat(web): add live DNS/TLS status badges to domains tab
Lftobs Aug 24, 2026
b21fcdb
feat(api): re-render all ingress routes when ingress server changes
Lftobs Aug 24, 2026
37db5b1
refactor(test): migrate integration tests to
Lftobs Aug 24, 2026
340b740
fix(api): add docker_tcp server support and
Lftobs Aug 24, 2026
0195b17
refactor(api): standardize API responses and
Lftobs Aug 24, 2026
0228a7f
chore: improve stability and environment security
Lftobs Aug 25, 2026
eaa9e7d
chore(release): v0.3.0-rc.1
Lftobs Aug 25, 2026
23ef255
fix(auth): add missing PAM service config for dequel group auth
Lftobs Aug 26, 2026
ff812f0
fix(e2e): resolve remote server deployment, buildkit, caddy ingress, …
Lftobs Aug 27, 2026
a3cacb8
fix(rc): use next tag for RC image testing
Lftobs Aug 28, 2026
e27b1b8
fix(rc): resolve login, caddy reload, and missing db migration
Lftobs Aug 28, 2026
a8389a9
fix(migration): make ssh_key migration idempotent for existing DBs
Lftobs Aug 28, 2026
32f7f8d
fix: add timeout to Loki fetch, default LogsTab to runtime mode
Lftobs Aug 28, 2026
c7acdcc
chore: bump version to v0.3.0-rc.4
Lftobs Aug 28, 2026
c24095f
refactor: remove Docker Compose profiles, start all services by default
Lftobs Aug 29, 2026
dfd99ce
chore: bump version to v0.3.0-rc.5
Lftobs Aug 29, 2026
3bca144
feat(ui): add SSH private key input to server form
Lftobs Aug 29, 2026
0e21aa4
chore: bump version to v0.3.0-rc.6
Lftobs Aug 29, 2026
9b73101
security: encrypt SSH private keys at rest
Lftobs Aug 29, 2026
133882a
chore: bump version to v0.3.0-rc.7
Lftobs Aug 29, 2026
54b9eed
chore: ignore local verify-dequel agent skills
Lftobs Aug 29, 2026
2627324
fix(ingress): default local ingress, emit :80 worker routes, plain ca…
Lftobs Aug 29, 2026
960ab4e
feat(cli): rc/prerelease update targets with zero-downtime rolling re…
Lftobs Aug 29, 2026
d9ccd2c
fix(caddy): update domain and email configuration
Lftobs Aug 30, 2026
6e6b7aa
fix: skip empty string subdomain in compose service validation
Lftobs Aug 30, 2026
28af211
Merge branch 'bugs-fixes' of github.com:Lftobs/dequel into bugs-fixes
Lftobs Aug 30, 2026
134b05d
feat: add Docker Compose deployment support for remote SSH servers
Lftobs Aug 30, 2026
c06e8ec
fix: use per-service ports and filter non-web containers in compose C…
Lftobs Aug 30, 2026
44be08c
fix: handle composeServices as both string and array (jsonb from Driz…
Lftobs Aug 30, 2026
c114ebd
fix: bind worker Caddy routes to HTTP :80 for ingress deployments
Lftobs Sep 1, 2026
9cf4ea1
fix(ci): bust GHA cache per-commit using BUILD_TIME arg
Lftobs Sep 1, 2026
a809798
fix: create ingress routes for compose subdomains (api, server, etc.)
Lftobs Sep 2, 2026
0393019
fix: include all compose services in webServices for ingress routing
Lftobs Sep 2, 2026
6459b28
fix: use actual container ports from remote compose result for Caddy …
Lftobs Sep 2, 2026
a5162e0
fix: use container-internal port for Caddy routes, not host-mapped port
Lftobs Sep 2, 2026
17ffcc5
fix: prevent empty catch-all domains in Caddy snippets
Lftobs Sep 2, 2026
1d52e18
feat(settings): add project deletion and
Lftobs Sep 3, 2026
f223ff5
Merge branch 'dev' into bugs-fixes
Lftobs Sep 3, 2026
af368aa
fix: add ON DELETE CASCADE to deployment_logs FK, bump to 0.3.0-rc.10
Lftobs Sep 3, 2026
aa194ec
fix: install docker compose plugin in API container for local compose…
Lftobs Sep 3, 2026
2145e66
fix: add Connect GitHub button to RepoPicker when session expires
Lftobs Sep 3, 2026
81de5e6
feat: persist GitHub OAuth sessions in DB with AES-256-GCM encryption
Lftobs Sep 3, 2026
8332ac8
fix(api): exclude database services from compose
Lftobs Sep 3, 2026
430384e
chore: release v0.3.0
Lftobs Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ jobs:
with:
context: apps/api
push: true
build-args: |
BUILD_TIME=${{ github.sha }}
tags: |
${{ env.API_IMAGE }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.API_IMAGE) || format('{0}:next', env.API_IMAGE) }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=api-${{ github.sha }}
cache-to: type=gha,scope=api-${{ github.sha }},mode=max

- name: Build and push Web image
uses: docker/build-push-action@v6
Expand All @@ -67,22 +69,25 @@ jobs:
push: true
build-args: |
DEQUEL_VERSION=${{ steps.version.outputs.VERSION }}
BUILD_TIME=${{ github.sha }}
tags: |
${{ env.WEB_IMAGE }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.WEB_IMAGE) || format('{0}:next', env.WEB_IMAGE) }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=web-${{ github.sha }}
cache-to: type=gha,scope=web-${{ github.sha }},mode=max

- name: Build and push Agent image
uses: docker/build-push-action@v6
with:
context: apps/agent
push: true
build-args: |
BUILD_TIME=${{ github.sha }}
tags: |
${{ env.AGENT_IMAGE }}:${{ steps.version.outputs.VERSION }}
${{ steps.version.outputs.IS_PRERELEASE == 'false' && format('{0}:latest', env.AGENT_IMAGE) || format('{0}:next', env.AGENT_IMAGE) }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=agent-${{ github.sha }}
cache-to: type=gha,scope=agent-${{ github.sha }},mode=max

- name: Build config tarball
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ apps/docs/.blume/
FEATURES.md
docs/plans/
.agents/skills/verify-dequel/
.worktrees/
artifacts
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.0-rc.8
0.3.0
2 changes: 2 additions & 0 deletions apps/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM oven/bun:1

ARG BUILD_TIME=0

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git caddy gnupg iproute2 wireguard-tools \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
Expand Down
2 changes: 1 addition & 1 deletion apps/agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dequel-agent",
"version": "0.3.0-rc.8",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ export const config = {
export const requireControlPlaneUrl = () => {
if (!config.controlPlaneUrl) throw new Error("DEQUEL_CONTROL_PLANE is required");
return config.controlPlaneUrl;
};
};
2 changes: 1 addition & 1 deletion apps/agent/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,4 @@ const applyRoute = async (payload: RemoteRoutePayload, signal: AbortSignal): Pro
return { routeFile: payload.routeFile, status: payload.action === "add" ? "active" : "removed" };
};

export const validateRoutePayload = validateRoutePayloadImpl;
export const validateRoutePayload = validateRoutePayloadImpl;
2 changes: 1 addition & 1 deletion apps/agent/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ export const parseP2PResponse = (raw: unknown): P2PResponse | null => {
} catch {
return null;
}
};
};
2 changes: 1 addition & 1 deletion apps/agent/src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ const parseMemToMb = (mem: string): number => {
case "KiB": case "KB": return val / 1024;
default: return val;
}
};
};
5 changes: 5 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM oven/bun:1

ARG BUILD_TIME=0

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
Expand All @@ -11,6 +13,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*

RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 && \
mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-x86_64" -o /usr/local/lib/docker/cli-plugins/docker-compose && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose && \
curl -sSL https://railpack.com/install.sh | sh -s -- --bin-dir /usr/local/bin


Expand Down
6 changes: 4 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dequel-api",
"version": "0.3.0-rc.8",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -19,8 +19,10 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@esbuild/linux-x64": "^0.28.2",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.10"
"drizzle-kit": "^0.31.10",
"esbuild": "^0.28.2"
}
}
2 changes: 1 addition & 1 deletion apps/api/src/agents/job-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,4 @@ export const processAgentJobUpdate = async (serverId: string, update: Exclude<P2
export const nextJobBatch = async (serverId: string) => {
const job = await leaseNextAgentJob(serverId);
return { jobs: job ? [job] : [], cancelJobIds: await listCancelledJobIds(serverId) };
};
};
2 changes: 1 addition & 1 deletion apps/api/src/agents/stats-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ class AgentStatsCache {
}
}

export const agentStatsCache = new AgentStatsCache();
export const agentStatsCache = new AgentStatsCache();
47 changes: 10 additions & 37 deletions apps/api/src/api/github/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,9 @@
import { Elysia } from "elysia";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getGithubIntegration, setGithubIntegration, createDeployment, listProjects } from "../../db/repo";
import { getGithubIntegration, setGithubIntegration, createDeployment, listProjects, getGithubSession, createGithubSession, deleteGithubSession } from "../../db/repo";
import { orchestrator } from "../../orchestrator";
import { config } from "../../utils/config";
import { ok, fail } from "../response";

const SESSIONS_FILE = join(process.env.DATA_DIR ?? "./data", ".github-sessions.json");

let SESSIONS = new Map<string, { token: string }>();

const loadSessions = () => {
try {
const raw = readFileSync(SESSIONS_FILE, "utf-8");
const entries: [string, { token: string }][] = JSON.parse(raw);
SESSIONS = new Map(entries);
} catch {}
};

const saveSessions = () => {
try {
const dir = SESSIONS_FILE.substring(0, SESSIONS_FILE.lastIndexOf("/"));
mkdirSync(dir, { recursive: true });
writeFileSync(SESSIONS_FILE, JSON.stringify([...SESSIONS]), "utf-8");
} catch {}
};

loadSessions();

const validateToken = async (token: string): Promise<boolean> => {
try {
const res = await fetch("https://api.github.com/user", {
Expand All @@ -43,21 +19,19 @@ const getSession = async (cookie: string | null): Promise<string | null> => {
if (!cookie) return null;
const match = cookie.match(/github_session=([^;]+)/);
if (!match) return null;
const session = SESSIONS.get(match[1]);
if (!session) return null;
const valid = await validateToken(session.token);
const token = await getGithubSession(match[1]);
if (!token) return null;
const valid = await validateToken(token);
if (!valid) {
SESSIONS.delete(match[1]);
saveSessions();
await deleteGithubSession(match[1]);
return null;
}
return session.token;
return token;
};

const createSession = (token: string): string => {
const createSession = async (token: string): Promise<string> => {
const id = crypto.randomUUID();
SESSIONS.set(id, { token });
saveSessions();
await createGithubSession(id, token);
return id;
};

Expand Down Expand Up @@ -188,7 +162,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" })
set.headers["Location"] = `${origin}/?github=error=${msg}`;
return;
}
const sessionId = createSession(data.access_token);
const sessionId = await createSession(data.access_token);
set.status = 302;
set.headers["Set-Cookie"] = `github_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=315360000`;
set.headers["Location"] = `${origin}/?github=connected`;
Expand Down Expand Up @@ -324,8 +298,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" })
const cookie = request.headers.get("cookie");
const match = cookie?.match(/github_session=([^;]+)/);
if (match) {
SESSIONS.delete(match[1]);
saveSessions();
await deleteGithubSession(match[1]);
}
set.headers["Set-Cookie"] = "github_session=; Path=/; Max-Age=0";
return ok(null, "GitHub disconnected");
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/db/migrations/0021_add_github_sessions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- GitHub sessions table with encrypted access tokens
CREATE TABLE IF NOT EXISTS github_sessions (
id text PRIMARY KEY,
access_token_encrypted text NOT NULL,
access_token_iv text NOT NULL,
access_token_tag text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
28 changes: 28 additions & 0 deletions apps/api/src/db/repo/github-sessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { eq } from "drizzle-orm";
import { getDb } from "../db-provider";
import { githubSessions } from "../schema";
import { encryptValue, decryptValue } from "../../utils/crypto";
import { config } from "../../utils/config";

export const getGithubSession = async (id: string): Promise<string | null> => {
const db = await getDb();
const [row] = await db.select().from(githubSessions).where(eq(githubSessions.id, id)).execute();
if (!row) return null;
return decryptValue(row.accessTokenEncrypted, row.accessTokenIv, row.accessTokenTag, config.envEncryptionKey);
};

export const createGithubSession = async (id: string, accessToken: string): Promise<void> => {
const db = await getDb();
const enc = encryptValue(accessToken, config.envEncryptionKey);
await db.insert(githubSessions).values({
id,
accessTokenEncrypted: enc.encrypted,
accessTokenIv: enc.iv,
accessTokenTag: enc.tag,
}).execute();
};

export const deleteGithubSession = async (id: string): Promise<void> => {
const db = await getDb();
await db.delete(githubSessions).where(eq(githubSessions.id, id)).execute();
};
4 changes: 3 additions & 1 deletion apps/api/src/db/repo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ export { createAlert, listAlerts, getAlertById, updateAlertEnabled, deleteAlert

export { getGithubIntegration, setGithubIntegration } from "./github";

export { getGithubSession, createGithubSession, deleteGithubSession } from "./github-sessions";

export { getSmtpSettings, upsertSmtpSettings } from "./settings";
export type { SmtpSettingsData } from "./settings";

export { upsertRoute, getRouteByHostname, listRoutes, listIngressRoutes, updateRouteStatus, deleteRouteByHostname, deleteRoute, deleteRoutesByDeployment } from "./routes";
export { upsertRoute, getRouteByHostname, listRoutes, listIngressRoutes, listRoutesByDeployment, updateRouteStatus, deleteRouteByHostname, deleteRoute, deleteRoutesByDeployment } from "./routes";

export { getPlatformSettings, setIngressServer } from "./platform-settings";
export type { Route } from "../../types";
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/db/repo/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ export const deleteRoutesByDeployment = async (deploymentId: string): Promise<vo
await db.delete(routes).where(eq(routes.deploymentId, deploymentId)).execute();
};

export const listRoutesByDeployment = async (deploymentId: string): Promise<Route[]> => {
const db = await getDb();
const rows = await db.select().from(routes).where(eq(routes.deploymentId, deploymentId)).execute();
return rows.map(mapRoute);
};

export const deleteRoute = async (id: string): Promise<void> => {
const db = await getDb();
await db.delete(routes).where(eq(routes.id, id)).execute();
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { pgTable, text, integer, real, boolean, serial, jsonb, timestamp, foreignKey, uniqueIndex, index } from "drizzle-orm/pg-core";

export const githubSessions = pgTable("github_sessions", {
id: text().primaryKey(),
accessTokenEncrypted: text("access_token_encrypted").notNull(),
accessTokenIv: text("access_token_iv").notNull(),
accessTokenTag: text("access_token_tag").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const githubIntegrations = pgTable("github_integrations", {
id: text().primaryKey(),
clientId: text("client_id").notNull(),
Expand Down Expand Up @@ -68,7 +76,7 @@ export const deploymentLogs = pgTable("deployment_logs", {
message: text().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id] }),
foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }),
uniqueIndex("idx_logs_dep_seq").on(table.deploymentId, table.sequence),
]);

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/executors/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,4 @@ export const agentExecutor: DeploymentExecutor = {
await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" });
await appendLog(deployment.id, "system", "Deployment cancelled by user");
},
};
};
Loading
Loading