Refactor project to transition from Rust backend to Node.js with Fastify; update Dockerfile and Docker configurations for new build process; enhance local development instructions in CONTRIBUTING.md; implement health checks in Docker Compose; update pnpm-lock.yaml with new dependencies for API and shared packages; revise README.md to reflect new stack and development setup.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled

This commit is contained in:
Denozordec
2026-06-19 12:06:32 +07:00
parent 0a0a1a92f1
commit d11414666f
2065 changed files with 15782 additions and 16699 deletions
+16 -25
View File
@@ -1,46 +1,37 @@
---
description: Backend API — при изменениях, затрагивающих UI, строго следовать shadcn Components/Blocks
globs: backend/**/*
globs: apps/api/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend API + shadcn/ui
Rust backend: `backend/src/` (Axum, sqlx). Frontend потребляет API через TanStack Query.
## Когда правило активно
Любое изменение в `backend/src/api/handlers/`, DTO, полей ответа, которые отображаются в UI.
Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Frontend — TanStack Query.
## Обязательный порядок
1. **Backend** — handler, валидация, тесты API
2. **Схемы frontend** — `apps/web/src/lib/schemas.ts`, `apps/web/src/queries/index.ts`
3. **UI** — **только** [shadcn Components](https://ui.shadcn.com/docs/components) и [Blocks](https://ui.shadcn.com/blocks)
1. **Backend** — route, service, Vitest (`app.inject()`)
2. **Схемы** — `@cfdm/shared` (не дублировать в `apps/web/src/lib/schemas.ts`)
3. **UI** — shadcn MCP ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
## Запрещено на frontend при доработке API
- Новые raw `<table>` / `<select>` / кастомные badge-цвета
- Кастомный CSS для отображения новых полей
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
- Самописные формы без `Field` + RHF + Zod
## Рекомендуемые shadcn-паттерны для типовых API
## shadcn-паттерны
Перед выбором паттерна — **MCP** `search_items_in_registries` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)).
| API-данные | UI (из docs) |
|------------|--------------|
| Список сущностей | `Table` в `DataTableCard` или `data-table` block |
| Создание записи | `Card` + `FieldGroup` + RHF |
| Enum/фильтр | `Select` |
| Статус | `StatusBadge` → shadcn `Badge` variants |
| Ошибка мутации | `sonner` `toast.error` |
| Пустой список | `Empty` |
| Сводка/метрики | `Card` section-cards ([dashboard-01](https://ui.shadcn.com/blocks)) |
| API-данные | UI |
|------------|-----|
| Список | `Table` / `DataTableCard` |
| Создание | `Card` + `FieldGroup` + RHF |
| Статус | `Badge` variants |
| Ошибка | `sonner` `toast.error` |
## Согласованность
- Имена полей JSON — camelCase или snake_case как в существующем API; типы в Zod должны совпадать
- Новый endpoint → `queryOptions` factory в `apps/web/src/queries/`, не inline в route
- JSON поля — snake_case как в существующем API
- Новый endpoint → `queryOptions` в `apps/web/src/queries/`
Главное правило frontend: [`frontend-shadcn.mdc`](frontend-shadcn.mdc) · monorepo: [`frontend-monorepo.mdc`](frontend-monorepo.mdc) · обзор: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc)
MCP backend: [`backend-mcp.mdc`](backend-mcp.mdc) · Fastify: [`backend-fastify.mdc`](backend-fastify.mdc)
+26
View File
@@ -0,0 +1,26 @@
---
description: Drizzle ORM + SQLite — schema, migrations, queries
globs: packages/db/**/*,apps/api/src/services/**/*
alwaysApply: false
---
# Backend Drizzle
MCP Context7 (`drizzle-orm`, `drizzle-kit`) — [`backend-mcp.mdc`](backend-mcp.mdc).
## Schema
- `packages/db/src/schema/` — source of truth
- Migrations: `drizzle-kit generate` / `migrate`
- WAL + `foreign_keys` при открытии SQLite
- Индекс `idx_dns_records_domain_cf_id` на `(domain_id, cf_record_id)`
## Queries
- Batch queries (`inArray`, JOINs) — не N+1 loops
- UNIQUE violations → `CONFLICT` (409)
- Multi-step → `db.transaction()`
## SQLite
См. [`sqlite.mdc`](sqlite.mdc).
+43
View File
@@ -0,0 +1,43 @@
---
description: Fastify API — слои, плагины, контракт ошибок
globs: apps/api/**/*
alwaysApply: false
---
# Backend Fastify
Стек: **Node.js 22**, **Fastify 5**, `@fastify/*` plugins, `@cfdm/shared`, `@cfdm/db`.
MCP — [`backend-mcp.mdc`](backend-mcp.mdc).
## Слои
```
routes/ → services/ → @cfdm/db (repositories)
↘ lib/cf-client.ts
```
- Routes — тонкие Fastify plugins (`fastify-plugin`)
- **Запрещено:** SQL в routes, `fetch` к CF вне `cf-client`
## Плагины (официальные)
`@fastify/jwt`, `@fastify/cors`, `@fastify/sensible`, `@fastify/static`, `@fastify/helmet`, `@fastify/rate-limit`, `@fastify/type-provider-zod`, `fastify-plugin`
## Ошибки
Формат: `{ error: { code, message } }`
Коды: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `CLOUDFLARE_ERROR`, `INTERNAL_ERROR`
## Правила
- Zod schemas только из `@cfdm/shared`
- `db.transaction()` для multi-step writes
- Операции >2s → async job (`sync_jobs` + `p-queue`)
- Env через Zod в `config.ts`; prod fail-fast на dev `JWT_SECRET`
- TypeScript strict; `function` для handlers/services
## API + UI
[`backend-api-ui.mdc`](backend-api-ui.mdc)
+28
View File
@@ -0,0 +1,28 @@
---
description: Обязательный порядок MCP перед backend-кодом (Fastify, Drizzle, Cloudflare API)
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend MCP — обязательно
Перед **любой** задачей в `apps/api`, `packages/db`, `packages/shared` — сначала MCP, не training data.
## Порядок
| Задача | MCP |
|--------|-----|
| Fastify plugins, routes, hooks | **Context7** `resolve-library-id` → `query-docs` (`fastify`, `@fastify/jwt`, `@fastify/type-provider-zod`) |
| Drizzle schema, queries, migrations | **Context7** (`drizzle-orm`, `drizzle-kit`, `better-sqlite3`) |
| Cloudflare DNS/Zones API | **`plugin-cloudflare-cloudflare-docs`** `search_cloudflare_documentation` |
| API + UI | `packages/shared` → **shadcn MCP** ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)) |
| E2E / cutover | **cursor-ide-browser** |
| Неизвестный инструмент | **user-mcp-on-demand** `search_tools` |
## Запрещено
- Угадывать API Fastify/Drizzle/CF из памяти
- Самописные аналоги `@fastify/*` (CORS, static, JWT, rate-limit)
- Дублировать Zod schemas вне `@cfdm/shared`
Связанные: [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-testing.mdc`](backend-testing.mdc).
+26
View File
@@ -0,0 +1,26 @@
---
description: Backend Vitest + Fastify inject
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend Testing
Vitest + `app.inject()` (встроено в Fastify).
## Требования
- Каждый route plugin → минимум 1 integration test
- Sync/DNS → parity fixtures
- `:memory:` SQLite для unit; file DB для integration
- `beforeEach` — fresh schema migrate
## Паттерн
```ts
const app = await buildApp({ db: testDb })
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
```
См. [`vitest-best-practices.mdc`](vitest-best-practices.mdc).
+4 -3
View File
@@ -12,11 +12,12 @@ alwaysApply: false
```
apps/web/ # Vite SPA (routes, queries, domain components)
packages/ui/ # @cfdm/ui — shadcn primitives, utils, hooks, globals.css
apps/api/ # Fastify API + static SPA in prod
packages/ui/ # @cfdm/ui — shadcn primitives
packages/shared/ # @cfdm/shared — Zod schemas, parse-fqdn
packages/db/ # @cfdm/db — Drizzle schema, repositories
```
`backend/` — Rust, **вне** npm workspaces.
## Два components.json
| Файл | Назначение |
+4 -45
View File
@@ -1,52 +1,11 @@
---
description: General Rust rules for safe, idiomatic application and library development
description: "DEPRECATED — Rust backend удалён. См. backend-fastify.mdc"
globs: ["**/*.rs", "Cargo.toml", "Cargo.lock"]
alwaysApply: false
---
# Rust General Rules
# Rust General Rules (deprecated)
## Project Structure
Проект использует **TypeScript + Fastify** (`apps/api`). Это правило не применяется.
- Keep crates focused and name modules by domain responsibility.
- Put reusable library code in `src/lib.rs` and binary entry points in `src/main.rs` or `src/bin/`.
- Keep public APIs small and documented.
- Use feature flags deliberately and document non-default features.
- Commit `Cargo.lock` for applications; follow the project convention for libraries.
## Ownership and Types
- Prefer borrowing over cloning when ownership is not needed.
- Use owned values at API boundaries when the callee must store data.
- Model domain states with enums and structs instead of strings or booleans.
- Use `Option<T>` for absence and `Result<T, E>` for fallible operations.
- Avoid `unwrap()` and `expect()` outside tests, examples, and process-startup invariants.
## Error Handling
- Use `thiserror` or project-standard custom errors for libraries.
- Use `anyhow` or project-standard context-rich errors for applications.
- Add context when crossing IO, network, database, or parsing boundaries.
- Do not discard errors with `_` unless explicitly documented.
## Concurrency and Async
- Use `Send` and `Sync` boundaries intentionally.
- Prefer message passing or owned task inputs for async work.
- Do not hold blocking locks across `.await`.
- Use `tokio::task::spawn_blocking` or equivalent for blocking CPU or IO in async applications.
- Propagate cancellation through futures rather than hiding it in detached tasks.
## Testing and Quality
- Run `cargo fmt` and `cargo clippy` before delivery.
- Add unit tests for pure logic and integration tests for public behavior.
- Use property tests for parsers, serializers, and state machines when useful.
- Use benchmarks only after identifying a real performance question.
## Common Mistakes
- Do not fight the borrow checker by adding unnecessary `Arc<Mutex<_>>`.
- Do not expose internal module structure through public APIs by accident.
- Do not allocate in hot loops without measuring.
- Do not use unsafe code unless the invariant is documented and tested.
См. [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc).
+1 -1
View File
@@ -24,7 +24,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s
## Backend → UI
При правках API с экранами: [`backend-api-ui.mdc`](backend-api-ui.mdc).
При правках API с экранами: [`backend-api-ui.mdc`](backend-api-ui.mdc). Backend: `apps/api` (Fastify + Drizzle).
## Язык
+8 -6
View File
@@ -40,13 +40,15 @@ Configure for **main** and **develop**:
## Local development
```bash
# Backend
cd backend
cp ../.env.example ../.env
cargo run
# Frontend (separate terminal)
cp .env.example .env
pnpm install
pnpm --filter @cfdm/shared build
pnpm --filter @cfdm/db build
# API (:8080)
pnpm --filter @cfdm/api dev
# Frontend (:5173, proxies /api → :8080)
pnpm --filter web dev
```
+22 -20
View File
@@ -1,36 +1,38 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS frontend-builder
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY packages/ui packages/ui
RUN pnpm --filter web build
COPY apps apps
COPY packages packages
COPY VERSION VERSION
RUN pnpm turbo build --filter=web --filter=@cfdm/api
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY backend/Cargo.toml backend/Cargo.lock* ./backend/
COPY backend/migrations ./backend/migrations/
COPY backend/src ./backend/src/
COPY --from=frontend-builder /app/apps/web/dist ./static/
WORKDIR /app/backend
ENV STATIC_DIR=/app/static
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
FROM node:22-bookworm-slim AS runtime
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=backend-builder /app/backend/target/release/cfdm-backend /app/cfdm-backend
COPY --from=frontend-builder /app/apps/web/dist /app/static
COPY VERSION /app/VERSION
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/apps/api/package.json ./apps/api/package.json
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=builder /app/packages/db/dist ./packages/db/dist
COPY --from=builder /app/packages/db/package.json ./packages/db/package.json
COPY --from=builder /app/packages/db/migrations ./packages/db/migrations
COPY --from=builder /app/apps/web/dist ./static
COPY VERSION ./VERSION
ENV STATIC_DIR=/app/static
ENV DATABASE_URL=sqlite:/data/app.db
ENV SERVER_PORT=8080
EXPOSE 8080
VOLUME ["/data"]
CMD ["/app/cfdm-backend"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "apps/api/dist/server.js"]
+11 -21
View File
@@ -1,26 +1,16 @@
# syntax=docker/dockerfile:1
FROM rust:1.85-bookworm AS test
FROM node:22-bookworm-slim AS test
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev curl && rm -rf /var/lib/apt/lists/*
# Node + pnpm for frontend tests
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs
RUN corepack enable
COPY backend/Cargo.toml backend/Cargo.lock* ./backend/
COPY backend/migrations ./backend/migrations/
COPY backend/src ./backend/src/
WORKDIR /app/backend
RUN cargo test --release
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml /app/
COPY apps/web/package.json /app/apps/web/
COPY packages/ui/package.json /app/packages/ui/
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile
COPY apps/web /app/apps/web
COPY packages/ui /app/packages/ui
RUN pnpm --filter web test
CMD ["sh", "-c", "cd /app/backend && cargo test && cd /app && pnpm --filter web test"]
COPY apps apps
COPY packages packages
RUN pnpm turbo build --filter=@cfdm/shared --filter=@cfdm/db --filter=@cfdm/api
CMD ["sh", "-c", "pnpm --filter @cfdm/api test && pnpm --filter web test"]
+16 -4
View File
@@ -23,16 +23,28 @@ Open http://localhost:8080 — default login `admin` / `admin` (dev only).
## Stack
- **Backend:** Rust, Axum, sqlx, SQLite
- **Backend:** Node.js 22, Fastify 5, Drizzle ORM, SQLite (`apps/api`)
- **Shared:** Zod schemas (`packages/shared`), DB layer (`packages/db`)
- **Frontend:** pnpm monorepo (`apps/web` + `packages/ui`), React, Vite, TanStack Router/Query/Table, shadcn/ui (base-nova), Recharts
- **CI:** Gitea Actions (Gitflow)
## Frontend development
## Development
```bash
pnpm install
pnpm --filter web dev # http://localhost:5173, proxies /api → :8080
pnpm --filter web build
pnpm --filter @cfdm/shared build
pnpm --filter @cfdm/db build
# API + SQLite (:8080)
pnpm --filter @cfdm/api dev
# Frontend (:5173, proxies /api → :8080)
pnpm --filter web dev
```
```bash
pnpm turbo build
pnpm turbo test
```
shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add <component>`
+2
View File
@@ -0,0 +1,2 @@
export { }
+1999
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@cfdm/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"start": "node dist/server.js",
"test": "vitest run"
},
"dependencies": {
"@cfdm/db": "workspace:*",
"@cfdm/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/schedule": "^6.0.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"@node-rs/argon2": "^2.0.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"toad-scheduler": "^4.0.1",
"zod": "^4.2.0"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
+111
View File
@@ -0,0 +1,111 @@
import { resolve } from "node:path";
import Fastify from "fastify";
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from "@fastify/type-provider-zod";
import type { AppConfig } from "./config.js";
import { loadConfig } from "./config.js";
import authPlugin from "./plugins/auth.js";
import cfClientPlugin from "./plugins/cf-client.js";
import { requireAuth } from "./plugins/auth.js";
import corsPlugin from "./plugins/cors.js";
import dbPlugin from "./plugins/db.js";
import errorHandlerPlugin from "./plugins/error-handler.js";
import { authRoutes, healthRoutes } from "./routes/health.js";
import { groupRoutes } from "./routes/groups.js";
import { serviceRoutes } from "./routes/services.js";
import { serviceGroupRoutes } from "./routes/service-groups.js";
import { serviceBindingRoutes } from "./routes/service-bindings.js";
import { domainRoutes } from "./routes/domains.js";
import { dnsRoutes } from "./routes/dns.js";
import { subdomainRoutes } from "./routes/subdomains.js";
import { certificateRoutes } from "./routes/certificates.js";
import { syncRoutes } from "./routes/sync.js";
import * as certificateService from "./services/certificate-service.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
config?: AppConfig;
memory?: boolean;
}
export async function buildApp(opts: BuildAppOptions = {}) {
const config = opts.config ?? loadConfig();
const app = Fastify({
logger: { level: config.logLevel },
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
await app.register(import("@fastify/sensible"));
await app.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
await app.register(import("@fastify/rate-limit"), {
max: 300,
timeWindow: "1 minute",
});
await app.register(corsPlugin);
await app.register(errorHandlerPlugin);
await app.register(dbPlugin, { config, memory: opts.memory });
await app.register(cfClientPlugin, { config });
await app.register(authPlugin, { config });
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: "/api/v1" });
await app.register(
async (protectedApi) => {
protectedApi.addHook("onRequest", requireAuth);
await protectedApi.register(groupRoutes);
await protectedApi.register(serviceRoutes);
await protectedApi.register(serviceGroupRoutes);
await protectedApi.register(serviceBindingRoutes);
await protectedApi.register(domainRoutes);
await protectedApi.register(dnsRoutes);
await protectedApi.register(subdomainRoutes);
await protectedApi.register(certificateRoutes);
await protectedApi.register(syncRoutes);
},
{ prefix: "/api/v1" },
);
const staticDir = config.staticDir ?? resolve(process.cwd(), "static");
if (config.staticDir !== null) {
await app.register(import("@fastify/static"), {
root: staticDir,
wildcard: false,
});
app.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile("index.html");
});
}
if (!opts.memory) {
await app.register(import("@fastify/schedule"));
const certTask = new AsyncTask(
"certificate-check",
async () => {
const n = await certificateService.runAllChecks(app.db);
app.log.info({ checked: n }, "certificate check completed");
},
(err) => {
app.log.warn({ err }, "certificate check failed");
},
);
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: config.certCheckCron },
certTask,
{ preventOverrun: true },
),
);
}
return app;
}
+32
View File
@@ -0,0 +1,32 @@
import { resolve } from "node:path";
export interface AppConfig {
databaseUrl: string;
cloudflareApiToken: string;
jwtSecret: string;
jwtTtlHours: number;
adminUsername: string;
adminPasswordHash: string;
serverPort: number;
staticDir: string | null;
certCheckCron: string;
logLevel: string;
}
export function loadConfig(): AppConfig {
return {
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me",
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPasswordHash:
process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
serverPort: Number(process.env.SERVER_PORT ?? "8080") || 8080,
staticDir: process.env.STATIC_DIR
? resolve(process.env.STATIC_DIR)
: null,
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
logLevel: process.env.LOG_LEVEL ?? "info",
};
}
+68
View File
@@ -0,0 +1,68 @@
import { NotFoundError, ConflictError } from "@cfdm/db";
import { ValidationError } from "@cfdm/shared";
export type ErrorCode =
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "CONFLICT"
| "CLOUDFLARE_ERROR"
| "INTERNAL_ERROR";
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = "AppError";
}
static notFound(message: string) {
return new AppError("NOT_FOUND", message, 404);
}
static validation(message: string) {
return new AppError("VALIDATION_ERROR", message, 400);
}
static unauthorized() {
return new AppError("UNAUTHORIZED", "unauthorized", 401);
}
static forbidden() {
return new AppError("FORBIDDEN", "forbidden", 403);
}
static conflict(message: string) {
return new AppError("CONFLICT", message, 409);
}
static cloudflare(message: string) {
return new AppError("CLOUDFLARE_ERROR", message, 502);
}
static internal(message: string) {
return new AppError("INTERNAL_ERROR", message, 500);
}
}
export function toAppError(err: unknown): AppError {
if (err instanceof AppError) return err;
if (err instanceof NotFoundError) return AppError.notFound(err.message);
if (err instanceof ConflictError) return AppError.conflict(err.message);
if (err instanceof ValidationError) return AppError.validation(err.message);
if (err instanceof Error) return AppError.internal(err.message);
return AppError.internal(String(err));
}
export function errorBody(err: AppError) {
return {
error: {
code: err.code,
message: err.message,
},
};
}
+152
View File
@@ -0,0 +1,152 @@
import type {
CfDnsRecord,
CfZone,
CreateDnsRecordPayload,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { withRetry, parseRetryAfter } from "./cf-retry.js";
const BASE_URL = "https://api.cloudflare.com/client/v4";
interface CfResponse<T> {
success: boolean;
result?: T;
errors?: Array<{ code: number; message: string }>;
}
export class CloudflareClient {
constructor(private readonly token: string) {}
private async handleResponse<T>(
response: Response,
operation: string,
): Promise<T> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
}
const body = (await response.json()) as CfResponse<T>;
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ??
"unknown cloudflare error";
throw AppError.cloudflare(`${operation}: ${msg}`);
}
if (body.result === undefined) {
throw AppError.cloudflare(`${operation}: empty result`);
}
return body.result;
}
async listZones(): Promise<CfZone[]> {
return withRetry(async () => {
const all: CfZone[] = [];
let page = 1;
while (true) {
const url = new URL(`${BASE_URL}/zones`);
url.searchParams.set("per_page", "50");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfZone[]>(
response,
"list_zones",
);
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < 50) break;
page += 1;
}
return all;
});
}
async getZone(zoneId: string): Promise<CfZone> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "get_zone");
}
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
return withRetry(async () => {
const all: CfDnsRecord[] = [];
let page = 1;
while (page <= 50) {
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw AppError.cloudflare(String(response.status));
}
const batch = await this.handleResponse<CfDnsRecord[]>(
response,
"list_dns_records",
);
if (batch.length === 0) break;
all.push(...batch);
page += 1;
}
return all;
});
}
async createDnsRecord(
zoneId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
});
return this.handleResponse(response, "create_dns_record");
}
async updateDnsRecord(
zoneId: string,
recordId: string,
payload: CreateDnsRecordPayload,
): Promise<CfDnsRecord> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30_000),
},
);
return this.handleResponse(response, "update_dns_record");
}
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
const response = await fetch(
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
},
);
await this.handleResponse(response, "delete_dns_record");
}
}
+28
View File
@@ -0,0 +1,28 @@
export async function withRetry<T>(
operation: () => Promise<T>,
maxAttempts = 3,
): Promise<T> {
let delay = 500;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await new Promise((r) => setTimeout(r, delay));
delay *= 2;
}
}
}
throw lastError;
}
export function parseRetryAfter(headers: Headers): number | null {
const value = headers.get("retry-after");
if (!value) return null;
const seconds = Number(value);
return Number.isFinite(seconds) ? seconds * 1000 : null;
}
+6
View File
@@ -0,0 +1,6 @@
export {
validateDnsRecord,
certStatusFromExpiry,
isValidIpv4,
ValidationError,
} from "@cfdm/shared";
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import fp from "fastify-plugin";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
async function authPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
await app.register(import("@fastify/jwt"), {
secret: opts.config.jwtSecret,
});
}
export async function requireAuth(request: FastifyRequest): Promise<void> {
const authHeader = request.headers.authorization ?? "";
const token = authHeader.startsWith("Bearer ")
? authHeader.slice(7)
: "";
if (!token) throw AppError.unauthorized();
try {
await request.jwtVerify();
} catch {
throw AppError.unauthorized();
}
}
export default fp(authPlugin, { name: "auth" });
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { CloudflareClient } from "../lib/cf-client.js";
import type { AppConfig } from "../config.js";
declare module "fastify" {
interface FastifyInstance {
cf: CloudflareClient;
config: AppConfig;
}
}
async function cfClientPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
app.decorate("config", opts.config);
app.decorate("cf", new CloudflareClient(opts.config.cloudflareApiToken));
}
export default fp(cfClientPlugin, { name: "cf-client" });
+12
View File
@@ -0,0 +1,12 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function corsPlugin(app: FastifyInstance) {
await app.register(import("@fastify/cors"), {
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
});
}
export default fp(corsPlugin, { name: "cors" });
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import {
createDb,
createMemoryDb,
healthCheck,
runMigrations,
type Db,
type Sqlite,
} from "@cfdm/db";
import type { AppConfig } from "../config.js";
declare module "fastify" {
interface FastifyInstance {
db: Db;
sqlite: Sqlite;
}
}
export interface DbPluginOptions {
config?: AppConfig;
memory?: boolean;
}
async function dbPlugin(
app: FastifyInstance,
opts: DbPluginOptions,
) {
const { db, sqlite } = opts.memory
? createMemoryDb()
: createDb(opts.config!.databaseUrl);
runMigrations(sqlite);
app.decorate("db", db);
app.decorate("sqlite", sqlite);
app.addHook("onClose", async () => {
sqlite.close();
});
}
export default fp(dbPlugin, { name: "db" });
export { healthCheck };
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
import { AppError, errorBody, toAppError } from "../errors.js";
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _request, reply) => {
if (reply.sent) return;
const appErr =
err.statusCode === 401
? AppError.unauthorized()
: toAppError(err);
reply.status(appErr.statusCode).send(errorBody(appErr));
});
}
export default fp(errorHandlerPlugin, { name: "error-handler" });
+29
View File
@@ -0,0 +1,29 @@
import type { FastifyInstance } from "fastify";
import * as certificateService from "../services/certificate-service.js";
export async function certificateRoutes(app: FastifyInstance) {
app.get("/certificates", async (request) => {
const query = request.query as { status?: string };
return certificateService.listCertificates(
request.server.db,
query.status,
);
});
app.get("/certificates/summary", async (request) => {
return certificateService.statusSummary(request.server.db);
});
app.post("/certificates/check", async (request) => {
const checked = await certificateService.runAllChecks(request.server.db);
return { checked };
});
app.get("/certificates/:id", async (request) => {
const { id } = request.params as { id: string };
return certificateService.getCertificate(
request.server.db,
Number(id),
);
});
}
+109
View File
@@ -0,0 +1,109 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as dnsService from "../services/dns-service.js";
export async function dnsRoutes(app: FastifyInstance) {
const createSchema = z.object({
record_type: z.string(),
name: z.string(),
content: z.string(),
ttl: z.number().optional(),
proxied: z.boolean().optional(),
priority: z.number().optional(),
});
app.get("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const q = request.query as Record<string, string | undefined>;
return dnsService.list(request.server.db, Number(id), {
record_type: q.record_type,
name: q.name,
content: q.content,
proxied: q.proxied != null ? q.proxied === "true" : undefined,
sync_status: q.sync_status,
q: q.q,
sort: q.sort ?? "name",
page: q.page ? Number(q.page) : 1,
limit: q.limit ? Number(q.limit) : 50,
});
});
app.post("/domains/:id/dns", async (request) => {
const { id } = request.params as { id: string };
const body = createSchema.parse(request.body);
return dnsService.create(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.post("/domains/:id/dns/bulk", async (request) => {
const { id } = request.params as { id: string };
const body = z
.object({ operations: z.array(z.record(z.unknown())) })
.parse(request.body);
return dnsService.bulk(
request.server.db,
request.server.cf,
Number(id),
body.operations as dnsService.BulkDnsOp[],
);
});
app.get("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
return dnsService.get(
request.server.db,
Number(id),
Number(recordId),
);
});
app.patch("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
return dnsService.update(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
request.body as dnsService.UpdateDnsRequest,
);
});
app.delete("/domains/:id/dns/:recordId", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
await dnsService.deleteRecord(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
);
return { deleted: true };
});
app.post("/domains/:id/dns/:recordId/resolve", async (request) => {
const { id, recordId } = request.params as {
id: string;
recordId: string;
};
const body = z.object({ source: z.string() }).parse(request.body);
return dnsService.resolveConflict(
request.server.db,
request.server.cf,
Number(id),
Number(recordId),
body,
);
});
}
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as domainService from "../services/domain-service.js";
export async function domainRoutes(app: FastifyInstance) {
const createSchema = z.object({
zone_name: z.string(),
group_id: z.number().nullable().optional(),
});
const updateSchema = z.object({
group_id: z.number().nullable().optional(),
status: z.string().optional(),
});
app.get("/domains", async (request) => {
const query = request.query as { group_id?: string };
const groupId = query.group_id ? Number(query.group_id) : undefined;
return domainService.listDomains(request.server.db, groupId);
});
app.post("/domains", async (request) => {
const body = createSchema.parse(request.body);
return domainService.createDomain(
request.server.db,
request.server.cf,
body.group_id ?? null,
body.zone_name,
);
});
app.get("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
return domainService.getDomain(request.server.db, Number(id));
});
app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body);
const existing = domainService.getDomain(request.server.db, Number(id));
return domainService.updateDomain(
request.server.db,
Number(id),
body.group_id !== undefined ? body.group_id : existing.group_id,
body.status ?? existing.status,
);
});
app.delete("/domains/:id", async (request) => {
const { id } = request.params as { id: string };
domainService.deleteDomain(request.server.db, Number(id));
return { deleted: true };
});
app.post("/domains/:id/import", async (request) => {
const { id } = request.params as { id: string };
const imported = await domainService.importZoneRecords(
request.server.db,
request.server.cf,
Number(id),
);
return { imported };
});
app.put("/domains/:id/services", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ service_ids: z.array(z.number()) }).parse(request.body);
const serviceIds = await domainService.setDomainServices(
request.server.db,
Number(id),
body.service_ids,
);
return { service_ids: serviceIds };
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as groupService from "../services/group-service.js";
export async function groupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
name: z.string(),
slug: z.string(),
});
app.get("/groups", async (request) => {
return groupService.listGroups(request.server.db);
});
app.post("/groups", async (request) => {
const body = bodySchema.parse(request.body);
return groupService.createGroup(request.server.db, body.name, body.slug);
});
app.get("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
return groupService.getGroupWithStats(request.server.db, Number(id));
});
app.patch("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return groupService.updateGroup(
request.server.db,
Number(id),
body.name,
body.slug,
);
});
app.delete("/groups/:id", async (request) => {
const { id } = request.params as { id: string };
groupService.deleteGroup(request.server.db, Number(id));
return { deleted: true };
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { healthCheck } from "../plugins/db.js";
import * as authService from "../services/auth.js";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async (request, reply) => {
healthCheck(request.server.sqlite);
return { status: "ok" };
});
app.get("/ready", async (request, reply) => {
healthCheck(request.server.sqlite);
let cloudflare = false;
if (request.server.config.cloudflareApiToken) {
try {
await request.server.cf.listZones();
cloudflare = true;
} catch {
cloudflare = false;
}
}
return {
status: cloudflare || !request.server.config.cloudflareApiToken
? "ready"
: "degraded",
database: true,
cloudflare,
};
});
}
export async function authRoutes(app: FastifyInstance) {
const loginSchema = z.object({
username: z.string(),
password: z.string(),
});
app.post("/auth/login", async (request, reply) => {
const body = loginSchema.parse(request.body);
const result = await authService.login(
request.server.config,
(payload) => request.server.jwt.sign(payload),
body,
);
return result;
});
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as bindingService from "../services/binding-service.js";
export async function serviceBindingRoutes(app: FastifyInstance) {
const createSchema = z.object({
domain_id: z.number(),
service_id: z.number(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
});
const updateSchema = z.object({
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
});
app.get("/service-bindings", async (request) => {
return bindingService.listAll(request.server.db);
});
app.post("/service-bindings", async (request) => {
const body = createSchema.parse(request.body);
return bindingService.create(
request.server.db,
request.server.cf,
body,
);
});
app.get("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
const { repos } = await import("@cfdm/db");
return repos.getBindingView(request.server.db, Number(id));
});
app.patch("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body);
return bindingService.update(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.delete("/service-bindings/:id", async (request) => {
const { id } = request.params as { id: string };
bindingService.remove(request.server.db, Number(id));
return { deleted: true };
});
app.get("/domains/:id/service-bindings", async (request) => {
const { id } = request.params as { id: string };
return bindingService.listByDomain(request.server.db, Number(id));
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceGroupRoutes(app: FastifyInstance) {
const bodySchema = z.object({
name: z.string(),
type: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
});
app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db);
});
app.post("/service-groups", async (request) => {
const body = bodySchema.parse(request.body);
return serviceConfig.createGroup(
request.server.db,
request.server.cf,
body,
);
});
app.patch("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
const body = bodySchema.parse(request.body);
return serviceConfig.updateGroup(
request.server.db,
request.server.cf,
Number(id),
body,
);
});
app.delete("/service-groups/:id", async (request) => {
const { id } = request.params as { id: string };
serviceConfig.deleteGroup(request.server.db, Number(id));
return { deleted: true };
});
app.patch("/service-groups/:id/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body);
return serviceConfig.toggleGroup(
request.server.db,
request.server.cf,
Number(id),
body.enabled,
);
});
}
+65
View File
@@ -0,0 +1,65 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
export async function serviceRoutes(app: FastifyInstance) {
const createSchema = z.object({
name: z.string(),
slug: z.string(),
service_group_id: z.number().nullable().optional(),
});
app.get("/services", async (request) => {
return serviceConfig.listViews(request.server.db);
});
app.post("/services", async (request) => {
const body = createSchema.parse(request.body);
const service = repos.createService(
request.server.db,
body.name,
body.slug,
);
if (body.service_group_id != null) {
repos.setServiceGroup(
request.server.db,
service.id,
body.service_group_id,
);
}
return serviceConfig.getView(request.server.db, service.id);
});
app.get("/services/:id", async (request) => {
const { id } = request.params as { id: string };
return serviceConfig.getView(request.server.db, Number(id));
});
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
return serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
request.body as serviceConfig.UpdateServiceConfigRequest,
);
});
app.delete("/services/:id", async (request) => {
const { id } = request.params as { id: string };
repos.deleteService(request.server.db, Number(id));
return { deleted: true };
});
app.patch("/services/:id/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body);
return serviceConfig.toggleService(
request.server.db,
request.server.cf,
Number(id),
body.enabled,
);
});
}
+52
View File
@@ -0,0 +1,52 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { repos } from "@cfdm/db";
export async function subdomainRoutes(app: FastifyInstance) {
app.get("/domains/:id/subdomains", async (request) => {
const { id } = request.params as { id: string };
repos.getDomain(request.server.db, Number(id));
return repos.listSubdomainsByDomain(request.server.db, Number(id));
});
app.post("/domains/:id/subdomains", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body);
const domain = repos.getDomain(request.server.db, Number(id));
const fqdn =
body.name === "@"
? domain.zone_name
: `${body.name}.${domain.zone_name}`;
return repos.createSubdomain(
request.server.db,
Number(id),
body.name,
fqdn,
);
});
app.get("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
return repos.getSubdomain(request.server.db, Number(id));
});
app.patch("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body);
const sub = repos.getSubdomain(request.server.db, Number(id));
const domain = repos.getDomain(request.server.db, sub.domain_id);
const fqdn = `${body.name}.${domain.zone_name}`;
return repos.updateSubdomain(
request.server.db,
Number(id),
body.name,
fqdn,
);
});
app.delete("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string };
repos.deleteSubdomain(request.server.db, Number(id));
return { deleted: true };
});
}
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyInstance } from "fastify";
import * as syncService from "../services/sync-service.js";
export async function syncRoutes(app: FastifyInstance) {
app.post("/sync", async (request) => {
const jobId = await syncService.syncAll(
request.server.db,
request.server.cf,
);
return { job_id: jobId };
});
app.post("/domains/:id/sync", async (request) => {
const { id } = request.params as { id: string };
const result = await syncService.syncDomain(
request.server.db,
request.server.cf,
Number(id),
);
return { job_id: result.jobId, changes: result.changes };
});
app.get("/sync/jobs/:id", async (request) => {
const { id } = request.params as { id: string };
return syncService.getJob(request.server.db, id);
});
}
+47
View File
@@ -0,0 +1,47 @@
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
for (const path of [
resolve(import.meta.dirname, "../../../.env"),
".env",
"../.env",
]) {
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
break;
}
const config = loadConfig();
if (!config.cloudflareApiToken) {
console.warn(
"CLOUDFLARE_API_TOKEN не задан — импорт доменов из Cloudflare недоступен",
);
}
const app = await buildApp({ config });
try {
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
app.log.info(`listening on ${config.serverPort}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
+40
View File
@@ -0,0 +1,40 @@
import { verify } from "@node-rs/argon2";
import type { JwtClaims, LoginRequest, LoginResponse } from "@cfdm/shared";
import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
export async function verifyPassword(
config: AppConfig,
password: string,
): Promise<void> {
if (config.adminPasswordHash === "devplaceholder") {
if (password === "admin") return;
throw AppError.unauthorized();
}
const ok = await verify(config.adminPasswordHash, password);
if (!ok) throw AppError.unauthorized();
}
export async function login(
config: AppConfig,
sign: (payload: JwtClaims) => string,
req: LoginRequest,
): Promise<LoginResponse> {
if (req.username !== config.adminUsername) {
throw AppError.unauthorized();
}
await verifyPassword(config, req.password);
const expiresAt = new Date(
Date.now() + config.jwtTtlHours * 60 * 60 * 1000,
);
const token = sign({
sub: req.username,
exp: Math.floor(expiresAt.getTime() / 1000),
});
return {
token,
expires_at: expiresAt.toISOString(),
};
}
+157
View File
@@ -0,0 +1,157 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { ServiceBindingView } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import * as dnsService from "./dns-service.js";
export interface CreateBindingRequest {
domain_id: number;
service_id: number;
hostname?: string;
target_ip?: string;
}
export interface UpdateBindingRequest {
service_id?: number;
hostname?: string;
target_ip?: string;
}
function normalizeHostname(hostname?: string): string {
const h = hostname?.trim();
return h ? h : "@";
}
async function syncTargetIp(
db: Db,
cf: CloudflareClient,
domainId: number,
bindingId: number,
hostname: string,
dnsRecordId: number | null,
targetIp: string,
): Promise<number> {
if (dnsRecordId) {
await dnsService.update(db, cf, domainId, dnsRecordId, {
record_type: "A",
name: hostname,
content: targetIp,
proxied: false,
});
return dnsRecordId;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: targetIp,
ttl: 1,
proxied: false,
});
repos.setBindingDnsRecordId(db, bindingId, record.id);
return record.id;
}
export function listAll(db: Db): ServiceBindingView[] {
return repos.listAllBindings(db);
}
export function listByDomain(db: Db, domainId: number): ServiceBindingView[] {
repos.getDomain(db, domainId);
return repos.listBindingsByDomain(db, domainId);
}
export async function create(
db: Db,
cf: CloudflareClient,
req: CreateBindingRequest,
): Promise<ServiceBindingView> {
repos.getDomain(db, req.domain_id);
repos.getService(db, req.service_id);
const hostname = normalizeHostname(req.hostname);
const binding = repos.insertBinding(
db,
req.domain_id,
req.service_id,
hostname,
null,
);
const ip = req.target_ip?.trim();
if (ip) {
await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip);
}
return repos.getBindingView(db, binding.id);
}
export async function update(
db: Db,
cf: CloudflareClient,
id: number,
req: UpdateBindingRequest,
): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id);
const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname
? normalizeHostname(req.hostname)
: existing.hostname;
repos.updateBindingFields(
db,
id,
serviceId,
hostname,
existing.dns_record_id,
);
const ip = req.target_ip?.trim();
if (ip) {
await syncTargetIp(
db,
cf,
existing.domain_id,
id,
hostname,
existing.dns_record_id,
ip,
);
}
return repos.getBindingView(db, id);
}
export function remove(db: Db, id: number): void {
repos.getBinding(db, id);
repos.deleteBinding(db, id);
}
export async function setDomainServices(
db: Db,
domainId: number,
serviceIds: number[],
): Promise<number[]> {
repos.getDomain(db, domainId);
for (const sid of serviceIds) {
repos.getService(db, sid);
}
const existing = repos.listBindingsByDomain(db, domainId);
for (const binding of existing) {
if (!serviceIds.includes(binding.service_id)) {
repos.deleteBinding(db, binding.id);
}
}
for (const sid of serviceIds) {
const already = existing.some((b) => b.service_id === sid);
if (!already) {
repos.insertBinding(db, domainId, sid, "@", null);
}
}
return repos
.listBindingsByDomain(db, domainId)
.map((b) => b.service_id);
}
@@ -0,0 +1,116 @@
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_UNKNOWN,
certStatusFromExpiry,
} from "@cfdm/shared";
export function listCertificates(
db: Db,
status?: string,
): Certificate[] {
return repos.listCertificates(db, status);
}
export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id);
}
export async function checkHostname(
hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> {
return new Promise((resolve) => {
const socket = connect({ host: hostname, port: 443, timeout: 10_000 });
socket.on("error", (e) =>
resolve({ expiresAt: null, error: e.message }),
);
socket.on("timeout", () => {
socket.destroy();
resolve({ expiresAt: null, error: "connection timeout" });
});
socket.on("connect", () => {
const tlsSocket = tlsConnect(
{ socket, servername: hostname, rejectUnauthorized: true },
() => {
const cert = tlsSocket.getPeerCertificate();
tlsSocket.end();
if (!cert?.valid_to) {
resolve({ expiresAt: null, error: "no peer certificates" });
return;
}
resolve({ expiresAt: new Date(cert.valid_to), error: null });
},
);
tlsSocket.on("error", (e) =>
resolve({ expiresAt: null, error: e.message }),
);
});
});
}
export async function checkAndStore(
db: Db,
domainId: number,
subdomainId: number | null,
hostname: string,
): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname);
if (error) {
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
null,
CERT_ERROR,
error,
);
}
if (expiresAt) {
const days = Math.floor(
(expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
);
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
expiresAt.toISOString(),
certStatusFromExpiry(days),
null,
);
}
return repos.upsertCertificateCheck(
db,
domainId,
subdomainId,
hostname,
null,
CERT_UNKNOWN,
"unknown expiry",
);
}
export async function runAllChecks(db: Db): Promise<number> {
let count = 0;
for (const domain of repos.listAllDomains(db)) {
await checkAndStore(db, domain.id, null, domain.zone_name);
count += 1;
}
for (const sub of repos.listAllSubdomains(db)) {
await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn);
count += 1;
}
return count;
}
export function statusSummary(db: Db): Array<[string, number]> {
return repos.countCertificatesByStatus(db);
}
+306
View File
@@ -0,0 +1,306 @@
import type { Db } from "@cfdm/db";
import { repos, type DnsListFilter } from "@cfdm/db";
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { validateDnsRecord } from "../lib/validators.js";
export interface CreateDnsRequest {
record_type: string;
name: string;
content: string;
ttl?: number;
proxied?: boolean;
priority?: number;
}
export interface UpdateDnsRequest {
record_type?: string;
name?: string;
content?: string;
ttl?: number;
proxied?: boolean;
priority?: number;
}
export interface BulkDnsOp {
action: string;
id?: number;
record?: CreateDnsRequest;
}
export interface BulkDnsResult {
id?: number;
success: boolean;
error?: string;
}
export interface ResolveDnsRequest {
source: string;
}
function toCfPayload(
recordType: string,
name: string,
content: string,
ttl: number,
proxied: boolean,
priority: number | null,
): CreateDnsRecordPayload {
return {
type: recordType.toUpperCase(),
name,
content,
ttl,
proxied,
priority: priority ?? undefined,
};
}
async function pushRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
cfZoneId: string,
record: DnsRecord,
): Promise<DnsRecord> {
const payload = toCfPayload(
record.record_type,
record.name,
record.content,
record.ttl,
record.proxied,
record.priority,
);
try {
const cfRec = record.cf_record_id
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
: await cf.createDnsRecord(cfZoneId, payload);
repos.updateDnsFields(
db,
record.id,
record.record_type,
record.name,
record.content,
record.ttl,
record.proxied,
record.priority,
SYNC_SYNCED,
cfRec.id ?? null,
null,
);
return repos.getDnsRecord(db, domainId, record.id);
} catch (e) {
repos.setDnsSyncStatus(
db,
record.id,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
export async function create(
db: Db,
cf: CloudflareClient,
domainId: number,
req: CreateDnsRequest,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const ttl = req.ttl ?? 1;
const proxied = req.proxied ?? false;
validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied);
const record = repos.insertDnsRecord(
db,
domainId,
req.record_type,
req.name,
req.content,
ttl,
proxied,
req.priority ?? null,
SYNC_PENDING_PUSH,
"local",
null,
);
return pushRecord(db, cf, domainId, domain.cf_zone_id, record);
}
export async function update(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
req: UpdateDnsRequest,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const existing = repos.getDnsRecord(db, domainId, recordId);
const recordType = req.record_type ?? existing.record_type;
const name = req.name ?? existing.name;
const content = req.content ?? existing.content;
const ttl = req.ttl ?? existing.ttl;
const proxied = req.proxied ?? existing.proxied;
const priority = req.priority ?? existing.priority;
validateDnsRecord(recordType, name, content, ttl, proxied);
repos.updateDnsFields(
db,
recordId,
recordType,
name,
content,
ttl,
proxied,
priority,
SYNC_PENDING_PUSH,
existing.cf_record_id,
null,
);
const updated = repos.getDnsRecord(db, domainId, recordId);
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
export async function deleteRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const record = repos.getDnsRecord(db, domainId, recordId);
repos.markDnsPendingDelete(db, recordId);
if (record.cf_record_id) {
try {
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
} catch (e) {
repos.setDnsSyncStatus(
db,
recordId,
SYNC_ERROR,
record.cf_record_id,
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
repos.deleteDnsRecord(db, recordId);
}
export function list(
db: Db,
domainId: number,
filter: DnsListFilter,
): DnsRecord[] {
repos.getDomain(db, domainId);
return repos.listDnsRecords(db, domainId, filter);
}
export function get(db: Db, domainId: number, recordId: number): DnsRecord {
return repos.getDnsRecord(db, domainId, recordId);
}
export async function bulk(
db: Db,
cf: CloudflareClient,
domainId: number,
ops: BulkDnsOp[],
): Promise<BulkDnsResult[]> {
const results: BulkDnsResult[] = [];
for (const op of ops) {
try {
if (op.action === "create") {
if (!op.record) throw AppError.validation("record required");
const r = await create(db, cf, domainId, op.record);
results.push({ id: r.id, success: true });
} else if (op.action === "update") {
if (op.id == null) throw AppError.validation("id required");
if (!op.record) throw AppError.validation("record required");
await update(db, cf, domainId, op.id, {
record_type: op.record.record_type,
name: op.record.name,
content: op.record.content,
ttl: op.record.ttl,
proxied: op.record.proxied,
priority: op.record.priority,
});
results.push({ id: op.id, success: true });
} else if (op.action === "delete") {
if (op.id == null) throw AppError.validation("id required");
await deleteRecord(db, cf, domainId, op.id);
results.push({ id: op.id, success: true });
} else {
results.push({
id: op.id,
success: false,
error: `unknown action: ${op.action}`,
});
}
} catch (e) {
results.push({
id: op.id,
success: false,
error: e instanceof Error ? e.message : String(e),
});
}
}
return results;
}
export async function resolveConflict(
db: Db,
cf: CloudflareClient,
domainId: number,
recordId: number,
req: ResolveDnsRequest,
): Promise<DnsRecord> {
const domain = repos.getDomain(db, domainId);
const record = repos.getDnsRecord(db, domainId, recordId);
if (record.sync_status !== SYNC_CONFLICT) {
throw AppError.validation("record is not in conflict state");
}
if (req.source === "cloudflare") {
if (record.cf_record_id) {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const r = remote.find((x) => x.id === record.cf_record_id);
if (r) {
repos.updateDnsFields(
db,
recordId,
r.type,
r.name,
r.content,
r.ttl,
r.proxied ?? false,
r.priority ?? null,
SYNC_SYNCED,
r.id ?? null,
null,
);
}
}
return repos.getDnsRecord(db, domainId, recordId);
}
if (req.source === "local") {
const updated = repos.getDnsRecord(db, domainId, recordId);
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
}
throw AppError.validation("source must be cloudflare or local");
}
+71
View File
@@ -0,0 +1,71 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Domain, DomainListItem } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import * as bindingService from "./binding-service.js";
import * as syncService from "./sync-service.js";
export function listDomains(
db: Db,
groupId?: number,
): DomainListItem[] {
return repos.listDomainsEnriched(db, groupId);
}
export function getDomain(db: Db, id: number): Domain {
return repos.getDomain(db, id);
}
export async function createDomain(
db: Db,
cf: CloudflareClient,
groupId: number | null,
zoneName: string,
): Promise<Domain> {
const trimmed = zoneName.trim();
const zones = await cf.listZones();
if (zones.length === 0) {
throw AppError.notFound(
"нет доступных зон в Cloudflare — проверьте CLOUDFLARE_API_TOKEN и права Zone:Read",
);
}
const zone = zones.find((z) => z.name.toLowerCase() === trimmed.toLowerCase());
if (!zone) {
const names = zones.map((z) => z.name).join(", ");
throw AppError.notFound(
`зона «${trimmed}» не найдена в Cloudflare. Доступные: ${names}`,
);
}
return repos.createDomain(db, groupId, zone.name, zone.id);
}
export function updateDomain(
db: Db,
id: number,
groupId: number | null,
status: string,
): Domain {
return repos.updateDomain(db, id, groupId, status);
}
export function deleteDomain(db: Db, id: number): void {
repos.deleteDomain(db, id);
}
export async function setDomainServices(
db: Db,
domainId: number,
serviceIds: number[],
): Promise<number[]> {
return bindingService.setDomainServices(db, domainId, serviceIds);
}
export async function importZoneRecords(
db: Db,
cf: CloudflareClient,
domainId: number,
): Promise<number> {
const domain = repos.getDomain(db, domainId);
return syncService.pullSync(db, cf, domain);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Group } from "@cfdm/shared";
export function listGroups(db: Db): Group[] {
return repos.listGroups(db);
}
export function createGroup(db: Db, name: string, slug: string): Group {
return repos.createGroup(db, name, slug);
}
export function updateGroup(
db: Db,
id: number,
name: string,
slug: string,
): Group {
return repos.updateGroup(db, id, name, slug);
}
export function deleteGroup(db: Db, id: number): void {
repos.deleteGroup(db, id);
}
export function getGroupWithStats(db: Db, id: number) {
return repos.getGroupWithStats(db, id);
}
@@ -0,0 +1,705 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
Service,
ServiceGroup,
ServiceGroupsResponse,
ServiceView,
} from "@cfdm/shared";
import {
SYNC_ERROR,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js";
import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
export interface ServiceDomainInput {
fqdn: string;
target_ips?: string[];
target_ip?: string;
}
export interface ToggleRequest {
enabled: boolean;
}
export interface ServiceGroupBody {
name: string;
type?: string;
icon?: string;
domain?: string;
}
export interface UpdateServiceConfigRequest {
name?: string;
slug?: string;
service_group_id?: number | null;
ips?: string[];
domains?: ServiceDomainInput[];
}
export function fqdnToDisplay(hostname: string, zoneName: string): string {
return hostname === "@" ? zoneName : `${hostname}.${zoneName}`;
}
export function parseFqdn(
fqdn: string,
knownZones: string[],
): { zoneName: string; hostname: string } {
const normalized = fqdn.trim().toLowerCase();
if (!normalized) throw AppError.validation("укажите FQDN");
const zones = [...knownZones].sort((a, b) => b.length - a.length);
for (const zone of zones) {
const zoneLower = zone.toLowerCase();
if (normalized === zoneLower) {
return { zoneName: zone, hostname: "@" };
}
const suffix = `.${zoneLower}`;
if (normalized.endsWith(suffix)) {
const prefix = normalized.slice(0, -suffix.length);
if (prefix) return { zoneName: zone, hostname: prefix };
}
}
throw AppError.validation(
`не удалось определить зону для «${fqdn}» — зона должна существовать в Cloudflare`,
);
}
function normalizeIps(ips: string[]): string[] {
const out: string[] = [];
for (const ip of ips) {
const trimmed = ip.trim();
if (!trimmed || !isValidIpv4(trimmed)) continue;
if (!out.includes(trimmed)) out.push(trimmed);
}
out.sort();
return out;
}
function aggregateSyncStatus(statuses: string[]): string | null {
if (statuses.length === 0) return null;
if (statuses.some((s) => s === SYNC_ERROR)) return SYNC_ERROR;
if (statuses.some((s) => s === SYNC_PENDING_PUSH)) return SYNC_PENDING_PUSH;
if (statuses.every((s) => s === SYNC_SYNCED)) return SYNC_SYNCED;
return statuses[0] ?? null;
}
async function collectKnownZones(
db: Db,
cf: CloudflareClient,
): Promise<string[]> {
const dbDomains = repos.listDomains(db);
const zones = dbDomains.map((d) => d.zone_name);
const cfZones = await cf.listZones();
for (const zone of cfZones) {
if (!zones.some((n) => n.toLowerCase() === zone.name.toLowerCase())) {
zones.push(zone.name);
}
}
return zones;
}
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
const ips = repos.listServiceIps(db, serviceId);
const bindings = repos.listBindingsByService(db, serviceId);
const domainViews = bindings.map((binding) => {
const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
zone_name: binding.zone_name,
hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
target_ips: targetIps,
sync_status: aggregateSyncStatus(statuses),
};
});
return {
id: service.id,
name: service.name,
slug: service.slug,
service_group_id: service.service_group_id,
subdomain: service.subdomain,
enabled: service.enabled,
computed_fqdn: null,
created_at: service.created_at,
updated_at: service.updated_at,
ips,
domains: domainViews,
};
}
export async function listViews(db: Db): Promise<ServiceView[]> {
return Promise.all(
repos.listServices(db).map((s) => buildView(db, s.id)),
);
}
export async function getView(db: Db, id: number): Promise<ServiceView> {
repos.getService(db, id);
return buildView(db, id);
}
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groups = repos.listServiceGroups(db);
const groupViews = await Promise.all(
groups.map(async (group) => {
const services = repos.listServicesByGroup(db, group.id);
const serviceViews = await Promise.all(
services.map((s) => buildView(db, s.id)),
);
return { ...group, services: serviceViews };
}),
);
const ungroupedServices = repos.listUngroupedServices(db);
const ungrouped = await Promise.all(
ungroupedServices.map((s) => buildView(db, s.id)),
);
return { groups: groupViews, ungrouped };
}
function shouldPushDns(db: Db, service: Service): boolean {
if (!service.enabled) return false;
if (!service.service_group_id) return true;
const group = repos.getServiceGroup(db, service.service_group_id);
return group.enabled;
}
async function syncBindingDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
return;
}
const refreshed = repos.listRecordsForBinding(db, bindingId);
let primaryId: number | null = null;
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
let recordId: number;
if (existing) {
if (existing.name !== hostname) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
content: ip,
proxied: false,
});
}
recordId = existing.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: ip,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
if (primaryId == null) primaryId = recordId;
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
}
async function cleanupBindingDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
): Promise<void> {
await syncBindingDns(db, cf, bindingId, domainId, hostname, []);
}
async function cleanupServiceDnsOnly(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const bindings = repos.listBindingsByService(db, serviceId);
for (const binding of bindings) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
}
function validateTargetIpsInPool(targetIps: string[], ips: string[]): void {
for (const ip of targetIps) {
if (!isValidIpv4(ip)) {
throw AppError.validation(`некорректный IPv4: ${ip}`);
}
if (!ips.includes(ip)) {
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
}
}
}
function bindingTargetIps(input: ServiceDomainInput): string[] {
const raw = input.target_ips
? input.target_ips
: input.target_ip?.trim()
? [input.target_ip.trim()]
: [];
const normalized = normalizeIps(raw);
if (raw.length > 0 && normalized.length === 0) {
throw AppError.validation("некорректные IP в привязке домена");
}
return normalized;
}
async function syncServiceBindingsToDns(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const ips = repos.listServiceIps(db, serviceId);
if (ips.length === 0) {
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) {
throw AppError.validation("настройте FQDN в редакторе сервиса");
}
for (const binding of bindings) {
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) {
throw AppError.validation(
`укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
);
}
validateTargetIpsInPool(targetIps, ips);
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
targetIps,
);
}
}
async function collectGroupDnsIps(
db: Db,
groupId: number,
): Promise<string[]> {
const services = repos.listServicesByGroup(db, groupId);
const ips: string[] = [];
for (const service of services) {
if (!service.enabled) continue;
const bindings = repos.listBindingsByService(db, service.id);
for (const binding of bindings) {
for (const ip of repos.listBindingIps(db, binding.id)) {
if (!ips.includes(ip)) ips.push(ip);
}
}
}
ips.sort();
return ips;
}
async function syncGroupDomainDnsRecords(
db: Db,
cf: CloudflareClient,
groupId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const existingRecords = repos.listGroupDnsRecords(db, groupId);
for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) {
repos.unlinkGroupDnsRecord(db, groupId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
if (desiredIps.length === 0) return;
const refreshed = repos.listGroupDnsRecords(db, groupId);
for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip);
if (existing) {
if (existing.name !== hostname) {
await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A",
name: hostname,
content: ip,
proxied: false,
});
}
continue;
}
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: ip,
ttl: 1,
proxied: false,
});
repos.linkGroupDnsRecord(db, groupId, record.id);
}
}
async function resolveDomainId(
db: Db,
cf: CloudflareClient,
zoneName: string,
): Promise<number> {
const trimmed = zoneName.trim();
if (!trimmed) throw AppError.validation("укажите имя зоны");
const existing = repos.findDomainByZoneName(db, trimmed);
if (existing) return existing.id;
const created = await domainService.createDomain(db, cf, null, trimmed);
return created.id;
}
async function cleanupGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
const domainValue = group.domain?.trim();
if (!domainValue) return;
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []);
}
async function syncGroupDomainDns(
db: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled) {
await cleanupGroupDomainDns(db, cf, groupId);
return;
}
const domainValue = group.domain?.trim();
if (!domainValue) return;
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const desiredIps = await collectGroupDnsIps(db, groupId);
await syncGroupDomainDnsRecords(
db,
cf,
groupId,
domainId,
hostname,
desiredIps,
);
}
async function syncGroupDomainForService(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<void> {
const service = repos.getService(db, serviceId);
if (!service.service_group_id) return;
await syncGroupDomainDns(db, cf, service.service_group_id);
}
async function syncEnabledServicesInGroup(
db: Db,
cf: CloudflareClient,
groupId: number,
): Promise<void> {
const group = repos.getServiceGroup(db, groupId);
if (!group.enabled || !group.domain?.trim()) return;
const services = repos.listServicesByGroup(db, groupId);
for (const service of services) {
if (service.enabled) {
await syncServiceBindingsToDns(db, cf, service.id);
}
}
await syncGroupDomainDns(db, cf, groupId);
}
async function normalizeGroupDomain(
db: Db,
cf: CloudflareClient,
domain?: string,
): Promise<string | null> {
const raw = domain?.trim();
if (!raw) return null;
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(raw, knownZones);
return fqdnToDisplay(hostname, zoneName);
}
async function cleanupStaleGroupFqdnBindings(
db: Db,
cf: CloudflareClient,
groupId: number,
fqdn: string,
): Promise<void> {
const knownZones = await collectKnownZones(db, cf);
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
if (hostname === "@") return;
const domain = repos.findDomainByZoneName(db, zoneName);
if (!domain) return;
const services = repos.listServicesByGroup(db, groupId);
for (const service of services) {
const binding = repos.findBinding(
db,
service.id,
domain.id,
hostname,
);
if (!binding) continue;
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
repos.deleteBinding(db, binding.id);
}
}
export async function updateConfig(
db: Db,
cf: CloudflareClient,
id: number,
req: UpdateServiceConfigRequest,
): Promise<ServiceView> {
if (req.name && req.slug) {
repos.updateService(db, id, req.name, req.slug);
} else if (req.name) {
const existing = repos.getService(db, id);
repos.updateService(db, id, req.name, existing.slug);
} else if (req.slug) {
const existing = repos.getService(db, id);
repos.updateService(db, id, existing.name, req.slug);
}
if (req.service_group_id !== undefined) {
repos.setServiceGroup(db, id, req.service_group_id);
}
const ipsUpdated = req.ips !== undefined;
const knownZones = await collectKnownZones(db, cf);
const ips = req.ips ? normalizeIps(req.ips) : repos.listServiceIps(db, id);
if (ipsUpdated) repos.replaceServiceIps(db, id, ips);
const keptBindingIds: number[] = [];
let service = repos.getService(db, id);
const pushDns = shouldPushDns(db, service);
if (req.domains) {
if (req.domains.length > 0) {
for (const input of req.domains) {
const fqdn = input.fqdn.trim();
if (!fqdn) continue;
const targetIps = bindingTargetIps(input);
validateTargetIpsInPool(targetIps, ips);
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName);
const binding =
repos.findBinding(db, id, domainId, hostname) ??
repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetIps);
if (pushDns) {
await syncBindingDns(
db,
cf,
binding.id,
domainId,
hostname,
targetIps,
);
}
}
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
for (const binding of removed) {
await cleanupBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
);
}
repos.deleteBindingsExcept(db, id, keptBindingIds);
}
} else if (ipsUpdated) {
const bindings = repos.listBindingsByService(db, id);
for (const binding of bindings) {
const targetIps = repos.listBindingIps(db, binding.id);
for (const ip of targetIps) {
if (!ips.includes(ip)) {
throw AppError.validation(
`IP ${ip} привязан к ${fqdnToDisplay(binding.hostname, binding.zone_name)}, но отсутствует в новом пуле адресов`,
);
}
}
}
}
service = repos.getService(db, id);
if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
}
return buildView(db, id);
}
export async function createGroup(
db: Db,
cf: CloudflareClient,
body: ServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const domain = await normalizeGroupDomain(db, cf, body.domain);
return repos.createServiceGroup(
db,
body.name,
groupType,
body.icon ?? null,
domain,
);
}
export async function updateGroup(
db: Db,
cf: CloudflareClient,
id: number,
body: ServiceGroupBody,
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const previous = repos.getServiceGroup(db, id);
const oldDomain = previous.domain?.trim();
if (oldDomain) {
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
await cleanupGroupDomainDns(db, cf, id);
}
const domain = await normalizeGroupDomain(db, cf, body.domain);
const group = repos.updateServiceGroup(
db,
id,
body.name,
groupType,
body.icon ?? null,
domain,
);
await syncEnabledServicesInGroup(db, cf, id);
return group;
}
export function deleteGroup(db: Db, id: number): void {
repos.deleteServiceGroup(db, id);
}
export async function toggleService(
db: Db,
cf: CloudflareClient,
serviceId: number,
enabled: boolean,
): Promise<ServiceView> {
const service = repos.getService(db, serviceId);
if (enabled && service.service_group_id) {
const group = repos.getServiceGroup(db, service.service_group_id);
if (!group.enabled) {
throw AppError.validation("сначала включите группу сервисов");
}
if (!group.domain?.trim()) {
throw AppError.validation("укажите домен у группы сервисов");
}
}
repos.setServiceEnabled(db, serviceId, enabled);
if (!enabled) {
await cleanupServiceDnsOnly(db, cf, serviceId);
await syncGroupDomainForService(db, cf, serviceId);
return buildView(db, serviceId);
}
await syncServiceBindingsToDns(db, cf, serviceId);
await syncGroupDomainForService(db, cf, serviceId);
return buildView(db, serviceId);
}
export async function toggleGroup(
db: Db,
cf: CloudflareClient,
groupId: number,
enabled: boolean,
): Promise<ServiceGroupsResponse> {
repos.setServiceGroupEnabled(db, groupId, enabled);
if (!enabled) {
const services = repos.listServicesByGroup(db, groupId);
for (const service of services) {
if (service.enabled) {
repos.setServiceEnabled(db, service.id, false);
await cleanupServiceDnsOnly(db, cf, service.id);
}
}
await cleanupGroupDomainDns(db, cf, groupId);
} else {
await syncEnabledServicesInGroup(db, cf, groupId);
}
return listGroupViews(db);
}
+141
View File
@@ -0,0 +1,141 @@
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Domain, SyncJob } from "@cfdm/shared";
import {
SYNC_CONFLICT,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsNameToSubdomainLabel,
subdomainLabelToFqdn,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
import { randomUUID } from "node:crypto";
export async function pullSync(
db: Db,
cf: CloudflareClient,
domain: Domain,
): Promise<number> {
const remote = await cf.listDnsRecords(domain.cf_zone_id);
const local = repos.listDnsByDomain(db, domain.id);
let changed = 0;
const remoteIds = new Set(
remote.map((r) => r.id).filter((id): id is string => Boolean(id)),
);
for (const cfRec of remote) {
const cfId = cfRec.id;
if (!cfId) continue;
const proxied = cfRec.proxied ?? false;
const existing = repos.findDnsByCfId(db, domain.id, cfId);
if (existing) {
const contentMatch =
existing.content === cfRec.content &&
existing.ttl === cfRec.ttl &&
existing.proxied === proxied &&
existing.name === cfRec.name &&
existing.record_type.toUpperCase() === cfRec.type.toUpperCase();
if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH) {
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
changed += 1;
} else if (contentMatch && existing.sync_status === SYNC_CONFLICT) {
repos.setDnsSyncStatus(db, existing.id, SYNC_SYNCED, cfId, null);
changed += 1;
}
} else {
repos.insertDnsRecord(
db,
domain.id,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
proxied,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
cfId,
);
changed += 1;
}
}
for (const rec of local) {
if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {
if (rec.sync_status !== "pending_delete") {
repos.setDnsSyncStatus(
db,
rec.id,
SYNC_CONFLICT,
rec.cf_record_id,
"missing in cloudflare",
);
changed += 1;
}
}
}
const labels = new Set<string>();
for (const rec of remote) {
const label = dnsNameToSubdomainLabel(rec.name, domain.zone_name);
if (label) labels.add(label);
}
for (const label of labels) {
const fqdn = subdomainLabelToFqdn(label, domain.zone_name);
repos.upsertSubdomain(db, domain.id, label, fqdn);
changed += 1;
}
repos.setDomainLastSynced(db, domain.id);
return changed;
}
export async function syncDomain(
db: Db,
cf: CloudflareClient,
domainId: number,
): Promise<{ jobId: string; changes: number }> {
const jobId = randomUUID();
repos.createSyncJob(db, jobId, domainId);
const domain = repos.getDomain(db, domainId);
try {
const changes = await pullSync(db, cf, domain);
repos.finishSyncJob(db, jobId, "completed", `${changes} changes`);
return { jobId, changes };
} catch (e) {
repos.finishSyncJob(
db,
jobId,
"failed",
e instanceof Error ? e.message : String(e),
);
throw e;
}
}
export async function syncAll(
db: Db,
cf: CloudflareClient,
): Promise<string> {
const jobId = randomUUID();
repos.createSyncJob(db, jobId, null);
const all = repos.listAllDomains(db);
let total = 0;
for (const domain of all) {
try {
total += await pullSync(db, cf, domain);
} catch {
// continue other domains
}
}
repos.finishSyncJob(db, jobId, "completed", `${total} total changes`);
return jobId;
}
export function getJob(db: Db, jobId: string): SyncJob {
return repos.getSyncJob(db, jobId);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
describe("health", () => {
it("GET /health returns ok", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
await app.close();
});
it("GET /ready returns database status", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const res = await app.inject({ method: "GET", url: "/ready" });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.database).toBe(true);
expect(["ready", "degraded"]).toContain(body.status);
await app.close();
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src", "test"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
typecheck: {
tsconfig: "./tsconfig.test.json",
},
},
});
@@ -165,7 +165,8 @@ export function ServiceEditSheet({
name: name.trim(),
slug: slug.trim(),
service_group_id: groupId,
...configPayload,
ips,
domains,
})
return
}
+3 -1
View File
@@ -12,7 +12,9 @@ export class ApiError extends Error {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem('cfdm_token')
const headers = new Headers(init?.headers)
headers.set('Content-Type', 'application/json')
if (init?.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(path, { ...init, headers })
+1 -1
View File
@@ -1,4 +1,4 @@
export interface ParsedFqdn {
export interface ParsedFqdn {
zoneName: string
hostname: string
fqdn: string
+1 -1
View File
@@ -1,4 +1,4 @@
import { z } from 'zod'
import { z } from 'zod'
export const subdomainSchema = z.object({
id: z.number(),
+18 -18
View File
@@ -1,4 +1,4 @@
import { z } from 'zod'
import { z } from 'zod'
export const groupSchema = z.object({
id: z.number(),
@@ -158,25 +158,25 @@ export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
export const createGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
slug: z.string().min(1, 'Укажите slug'),
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug'),
})
const ipv4Schema = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
'Некорректный IPv4',
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
)
const serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, 'Укажите FQDN'),
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'),
fqdn: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ FQDN'),
target_ips: z.array(ipv4Schema).min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Е╨╛╤В╤П ╨▒╤Л ╨╛╨┤╨╕╨╜ IP'),
})
export const createServiceSchema = z.object({
name: z.string().min(1, 'Укажите название'),
slug: z.string().min(1, 'Укажите slug'),
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug'),
})
export const createServiceWithConfigSchema = createServiceSchema.extend({
@@ -186,8 +186,8 @@ export const createServiceWithConfigSchema = createServiceSchema.extend({
})
export const createServiceBindingSchema = z.object({
domain_id: z.string().min(1, 'Выберите домен'),
service_id: z.string().min(1, 'Выберите сервис'),
domain_id: z.string().min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╨┤╨╛╨╝╨╡╨╜'),
service_id: z.string().min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Б╨╡╤А╨▓╨╕╤Б'),
hostname: z.string().optional(),
target_ip: z.string().optional(),
})
@@ -198,19 +198,19 @@ export const updateDomainGroupSchema = z.object({
})
export const createDomainSchema = z.object({
zone_name: z.string().min(1, 'Укажите имя зоны'),
zone_name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П ╨╖╨╛╨╜╤Л'),
group_id: z.string(),
})
export const loginSchema = z.object({
username: z.string().min(1, 'Укажите имя пользователя'),
password: z.string().min(1, 'Укажите пароль'),
username: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П ╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╤В╨╡╨╗╤П'),
password: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨┐╨░╤А╨╛╨╗╤М'),
})
export const createDnsRecordSchema = z.object({
record_type: z.enum(['A', 'AAAA', 'CNAME', 'TXT', 'MX']),
name: z.string().min(1, 'Укажите имя'),
content: z.string().min(1, 'Укажите значение'),
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П'),
content: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╖╨╜╨░╤З╨╡╨╜╨╕╨╡'),
ttl: z.number().int().min(1),
proxied: z.boolean(),
})
@@ -220,8 +220,8 @@ export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
export const updateServiceConfigSchema = z.object({
name: z.string().min(1, 'Укажите название').optional(),
slug: z.string().min(1, 'Укажите slug').optional(),
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(),
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(),
service_group_id: z.number().nullable().optional(),
ips: z.array(ipv4Schema).optional(),
domains: z
@@ -232,7 +232,7 @@ export const updateServiceConfigSchema = z.object({
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
export const createServiceGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
type: serviceGroupTypeSchema.default('custom'),
icon: z.string().nullable().optional(),
domain: z.string().nullable().optional(),
-2
View File
@@ -16,8 +16,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
-3302
View File
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "cfdm-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
jsonwebtoken = "9"
argon2 = "0.5"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio-cron-scheduler = "0.14"
rustls = { version = "0.23", features = ["ring"] }
tokio-rustls = "0.26"
webpki-roots = "0.26"
x509-parser = "0.16"
uuid = { version = "1", features = ["v4"] }
regex = "1"
[dev-dependencies]
tokio-test = "0.4"
-35
View File
@@ -1,35 +0,0 @@
use axum::{
extract::State,
http::{header::AUTHORIZATION, Request},
middleware::Next,
response::Response,
};
use crate::error::AppError;
use crate::services::auth::{self, LoginRequest};
use crate::state::AppState;
pub async fn login(
State(state): State<AppState>,
axum::Json(body): axum::Json<LoginRequest>,
) -> Result<axum::Json<auth::LoginResponse>, AppError> {
let resp = auth::login(&state.config, body)?;
Ok(axum::Json(resp))
}
pub async fn require_auth(
State(state): State<AppState>,
req: Request<axum::body::Body>,
next: Next,
) -> Result<Response, AppError> {
let auth_header = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
if token.is_empty() {
return Err(AppError::Unauthorized);
}
auth::validate_token(&state.config, token)?;
Ok(next.run(req).await)
}
-39
View File
@@ -1,39 +0,0 @@
use crate::error::AppResult;
use crate::services::certificate_service;
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CertListQuery {
pub status: Option<String>,
}
pub async fn list(
State(state): State<AppState>,
Query(q): Query<CertListQuery>,
) -> AppResult<axum::Json<Vec<crate::domain::Certificate>>> {
Ok(axum::Json(
certificate_service::list_certificates(&state.pool, q.status.as_deref()).await?,
))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Certificate>> {
Ok(axum::Json(certificate_service::get_certificate(&state.pool, id).await?))
}
pub async fn check_all(
State(state): State<AppState>,
) -> AppResult<axum::Json<serde_json::Value>> {
let count = certificate_service::run_all_checks(&state.pool).await?;
Ok(axum::Json(serde_json::json!({ "checked": count })))
}
pub async fn summary(
State(state): State<AppState>,
) -> AppResult<axum::Json<Vec<(String, i64)>>> {
Ok(axum::Json(certificate_service::status_summary(&state.pool).await?))
}
-96
View File
@@ -1,96 +0,0 @@
use crate::error::AppResult;
use crate::repositories::dns_records::DnsListFilter;
use crate::services::dns_service::{self, BulkDnsOp, CreateDnsRequest, ResolveDnsRequest, UpdateDnsRequest};
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct DnsListQuery {
pub record_type: Option<String>,
pub name: Option<String>,
pub content: Option<String>,
pub proxied: Option<bool>,
pub sync_status: Option<String>,
pub q: Option<String>,
pub sort: Option<String>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct BulkBody {
pub operations: Vec<BulkDnsOp>,
}
pub async fn list(
State(state): State<AppState>,
Path(id): Path<i64>,
Query(q): Query<DnsListQuery>,
) -> AppResult<axum::Json<Vec<crate::domain::DnsRecord>>> {
let filter = DnsListFilter {
record_type: q.record_type,
name: q.name,
content: q.content,
proxied: q.proxied,
sync_status: q.sync_status,
q: q.q,
sort: q.sort.unwrap_or_else(|| "name".into()),
page: q.page.unwrap_or(1),
limit: q.limit.unwrap_or(50),
};
Ok(axum::Json(dns_service::list(&state.pool, id, filter).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path((id, record_id)): Path<(i64, i64)>,
) -> AppResult<axum::Json<crate::domain::DnsRecord>> {
Ok(axum::Json(dns_service::get(&state.pool, id, record_id).await?))
}
pub async fn create(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<CreateDnsRequest>,
) -> AppResult<axum::Json<crate::domain::DnsRecord>> {
Ok(axum::Json(dns_service::create(&state.pool, &state.cf, id, body).await?))
}
pub async fn update(
State(state): State<AppState>,
Path((id, record_id)): Path<(i64, i64)>,
axum::Json(body): axum::Json<UpdateDnsRequest>,
) -> AppResult<axum::Json<crate::domain::DnsRecord>> {
Ok(axum::Json(
dns_service::update(&state.pool, &state.cf, id, record_id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path((id, record_id)): Path<(i64, i64)>,
) -> AppResult<axum::Json<serde_json::Value>> {
dns_service::delete_record(&state.pool, &state.cf, id, record_id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn bulk(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<BulkBody>,
) -> AppResult<axum::Json<Vec<dns_service::BulkDnsResult>>> {
Ok(axum::Json(
dns_service::bulk(&state.pool, &state.cf, id, body.operations).await?,
))
}
pub async fn resolve(
State(state): State<AppState>,
Path((id, record_id)): Path<(i64, i64)>,
axum::Json(body): axum::Json<ResolveDnsRequest>,
) -> AppResult<axum::Json<crate::domain::DnsRecord>> {
Ok(axum::Json(
dns_service::resolve_conflict(&state.pool, &state.cf, id, record_id, body).await?,
))
}
-93
View File
@@ -1,93 +0,0 @@
use crate::error::AppResult;
use crate::services::domain_service;
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct DomainListQuery {
pub group_id: Option<i64>,
}
#[derive(Deserialize)]
pub struct CreateDomainBody {
pub zone_name: String,
pub group_id: Option<i64>,
}
#[derive(Deserialize)]
pub struct UpdateDomainBody {
pub group_id: Option<i64>,
pub status: Option<String>,
}
#[derive(Deserialize)]
pub struct SetServicesBody {
pub service_ids: Vec<i64>,
}
pub async fn list(
State(state): State<AppState>,
Query(q): Query<DomainListQuery>,
) -> AppResult<axum::Json<Vec<crate::domain::DomainListItem>>> {
Ok(axum::Json(domain_service::list_domains(&state.pool, q.group_id).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Domain>> {
Ok(axum::Json(domain_service::get_domain(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<CreateDomainBody>,
) -> AppResult<axum::Json<crate::domain::Domain>> {
Ok(axum::Json(
domain_service::create_domain(
&state.pool,
&state.cf,
body.group_id,
&body.zone_name,
)
.await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<UpdateDomainBody>,
) -> AppResult<axum::Json<crate::domain::Domain>> {
let existing = domain_service::get_domain(&state.pool, id).await?;
let status = body.status.unwrap_or(existing.status);
Ok(axum::Json(
domain_service::update_domain(&state.pool, id, body.group_id, &status).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
domain_service::delete_domain(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn import_zone(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
let count = domain_service::import_zone_records(&state.pool, &state.cf, id).await?;
Ok(axum::Json(serde_json::json!({ "imported": count })))
}
pub async fn set_services(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<SetServicesBody>,
) -> AppResult<axum::Json<serde_json::Value>> {
let ids = domain_service::set_domain_services(&state.pool, id, body.service_ids).await?;
Ok(axum::Json(serde_json::json!({ "service_ids": ids })))
}
-49
View File
@@ -1,49 +0,0 @@
use crate::error::AppResult;
use crate::services::group_service;
use crate::state::AppState;
use axum::extract::{Path, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct GroupBody {
pub name: String,
pub slug: String,
}
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::Group>>> {
Ok(axum::Json(group_service::list_groups(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::GroupWithStats>> {
Ok(axum::Json(crate::repositories::groups::get_with_stats(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<GroupBody>,
) -> AppResult<axum::Json<crate::domain::Group>> {
Ok(axum::Json(
group_service::create_group(&state.pool, &body.name, &body.slug).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<GroupBody>,
) -> AppResult<axum::Json<crate::domain::Group>> {
Ok(axum::Json(
group_service::update_group(&state.pool, id, &body.name, &body.slug).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
group_service::delete_group(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
-27
View File
@@ -1,27 +0,0 @@
use crate::error::AppResult;
use crate::state::AppState;
use axum::extract::State;
use serde_json::json;
pub async fn health(State(state): State<AppState>) -> AppResult<axum::Json<serde_json::Value>> {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.pool)
.await?;
Ok(axum::Json(json!({ "status": "ok" })))
}
pub async fn ready(State(state): State<AppState>) -> AppResult<axum::Json<serde_json::Value>> {
sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.pool)
.await?;
let cf_ok = if state.config.cloudflare_api_token.is_empty() {
false
} else {
state.cf.list_zones().await.is_ok()
};
Ok(axum::Json(json!({
"status": if cf_ok { "ready" } else { "degraded" },
"database": true,
"cloudflare": cf_ok,
})))
}
-11
View File
@@ -1,11 +0,0 @@
pub mod auth;
pub mod certificates;
pub mod dns;
pub mod domains;
pub mod groups;
pub mod health;
pub mod service_bindings;
pub mod service_groups;
pub mod services;
pub mod subdomains;
pub mod sync;
@@ -1,53 +0,0 @@
use crate::error::AppResult;
use crate::services::binding_service::{self, CreateBindingRequest, UpdateBindingRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::ServiceBindingView>>> {
Ok(axum::Json(binding_service::list_all(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
crate::repositories::service_bindings::get_view(&state.pool, id).await?,
))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<CreateBindingRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
binding_service::create(&state.pool, &state.cf, body).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<UpdateBindingRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceBindingView>> {
Ok(axum::Json(
binding_service::update(&state.pool, &state.cf, id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
binding_service::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn list_by_domain(
State(state): State<AppState>,
Path(domain_id): Path<i64>,
) -> AppResult<axum::Json<Vec<crate::domain::ServiceBindingView>>> {
Ok(axum::Json(
binding_service::list_by_domain(&state.pool, domain_id).await?,
))
}
@@ -1,49 +0,0 @@
use crate::error::AppResult;
use crate::services::service_config_service::{self, ServiceGroupBody, ToggleRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
pub async fn list(
State(state): State<AppState>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::list_group_views(&state.pool).await?,
))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::create_group(&state.pool, &state.cf, &body).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ServiceGroupBody>,
) -> AppResult<axum::Json<crate::domain::ServiceGroup>> {
Ok(axum::Json(
service_config_service::update_group(&state.pool, &state.cf, id, &body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
service_config_service::delete_group(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceGroupsResponse>> {
Ok(axum::Json(
service_config_service::toggle_group(&state.pool, &state.cf, id, body.enabled).await?,
))
}
-62
View File
@@ -1,62 +0,0 @@
use crate::error::AppResult;
use crate::services::service_config_service::{self, ToggleRequest, UpdateServiceConfigRequest};
use crate::state::AppState;
use axum::extract::{Path, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct ServiceBody {
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
}
pub async fn list(State(state): State<AppState>) -> AppResult<axum::Json<Vec<crate::domain::ServiceView>>> {
Ok(axum::Json(service_config_service::list_views(&state.pool).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(service_config_service::get_view(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
axum::Json(body): axum::Json<ServiceBody>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
let service = crate::repositories::services::create(&state.pool, &body.name, &body.slug).await?;
if let Some(group_id) = body.service_group_id {
crate::repositories::services::set_group(&state.pool, service.id, Some(group_id)).await?;
}
Ok(axum::Json(service_config_service::get_view(&state.pool, service.id).await?))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<UpdateServiceConfigRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::update_config(&state.pool, &state.cf, id, body).await?,
))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
crate::repositories::services::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
pub async fn toggle(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<ToggleRequest>,
) -> AppResult<axum::Json<crate::domain::ServiceView>> {
Ok(axum::Json(
service_config_service::toggle_service(&state.pool, &state.cf, id, body.enabled).await?,
))
}
-59
View File
@@ -1,59 +0,0 @@
use crate::error::AppResult;
use crate::repositories::subdomains as sub_repo;
use crate::state::AppState;
use axum::extract::{Path, State};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct SubdomainBody {
pub name: String,
}
pub async fn list(
State(state): State<AppState>,
Path(domain_id): Path<i64>,
) -> AppResult<axum::Json<Vec<crate::domain::Subdomain>>> {
Ok(axum::Json(sub_repo::list_by_domain(&state.pool, domain_id).await?))
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<crate::domain::Subdomain>> {
Ok(axum::Json(sub_repo::get(&state.pool, id).await?))
}
pub async fn create(
State(state): State<AppState>,
Path(domain_id): Path<i64>,
axum::Json(body): axum::Json<SubdomainBody>,
) -> AppResult<axum::Json<crate::domain::Subdomain>> {
let domain = crate::repositories::domains::get(&state.pool, domain_id).await?;
let fqdn = if body.name == "@" {
domain.zone_name.clone()
} else {
format!("{}.{}", body.name, domain.zone_name)
};
Ok(axum::Json(
sub_repo::create(&state.pool, domain_id, &body.name, &fqdn).await?,
))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
axum::Json(body): axum::Json<SubdomainBody>,
) -> AppResult<axum::Json<crate::domain::Subdomain>> {
let sub = sub_repo::get(&state.pool, id).await?;
let domain = crate::repositories::domains::get(&state.pool, sub.domain_id).await?;
let fqdn = format!("{}.{}", body.name, domain.zone_name);
Ok(axum::Json(sub_repo::update(&state.pool, id, &body.name, &fqdn).await?))
}
pub async fn delete(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
sub_repo::delete(&state.pool, id).await?;
Ok(axum::Json(serde_json::json!({ "deleted": true })))
}
-29
View File
@@ -1,29 +0,0 @@
use crate::error::AppResult;
use crate::services::sync_service;
use crate::state::AppState;
use axum::extract::{Path, State};
pub async fn sync_domain(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<axum::Json<serde_json::Value>> {
let (job_id, changes) = sync_service::sync_domain(&state.pool, &state.cf, id).await?;
Ok(axum::Json(serde_json::json!({
"job_id": job_id,
"changes": changes,
})))
}
pub async fn sync_all(
State(state): State<AppState>,
) -> AppResult<axum::Json<serde_json::Value>> {
let job_id = sync_service::sync_all(&state.pool, &state.cf).await?;
Ok(axum::Json(serde_json::json!({ "job_id": job_id })))
}
pub async fn get_job(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<axum::Json<crate::domain::SyncJob>> {
Ok(axum::Json(sync_service::get_job(&state.pool, &id).await?))
}
-4
View File
@@ -1,4 +0,0 @@
pub mod router;
pub mod handlers;
pub use router::create_router;
-117
View File
@@ -1,117 +0,0 @@
use super::handlers::{auth, certificates, dns, domains, groups, health, service_bindings, service_groups, services, subdomains, sync};
use crate::state::AppState;
use axum::{
middleware,
routing::{get, post, put},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
pub fn create_router(state: AppState) -> Router {
let static_dir = state
.config
.static_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("./static"));
let index = static_dir.join("index.html");
let static_service = ServeDir::new(static_dir).not_found_service(ServeFile::new(index));
let protected = Router::new()
.route("/groups", get(groups::list).post(groups::create))
.route(
"/groups/{id}",
get(groups::get_one)
.patch(groups::update)
.delete(groups::delete),
)
.route("/services", get(services::list).post(services::create))
.route(
"/services/{id}",
get(services::get_one)
.patch(services::update)
.delete(services::delete),
)
.route("/services/{id}/toggle", axum::routing::patch(services::toggle))
.route(
"/service-groups",
get(service_groups::list).post(service_groups::create),
)
.route(
"/service-groups/{id}",
axum::routing::patch(service_groups::update).delete(service_groups::delete),
)
.route(
"/service-groups/{id}/toggle",
axum::routing::patch(service_groups::toggle),
)
.route(
"/service-bindings",
get(service_bindings::list).post(service_bindings::create),
)
.route(
"/service-bindings/{id}",
get(service_bindings::get_one)
.patch(service_bindings::update)
.delete(service_bindings::delete),
)
.route("/domains", get(domains::list).post(domains::create))
.route(
"/domains/{id}",
get(domains::get_one)
.patch(domains::update)
.delete(domains::delete),
)
.route("/domains/{id}/import", post(domains::import_zone))
.route("/domains/{id}/services", put(domains::set_services))
.route(
"/domains/{id}/service-bindings",
get(service_bindings::list_by_domain),
)
.route("/domains/{id}/dns", get(dns::list).post(dns::create))
.route("/domains/{id}/dns/bulk", post(dns::bulk))
.route(
"/domains/{id}/dns/{record_id}",
get(dns::get_one)
.patch(dns::update)
.delete(dns::delete),
)
.route("/domains/{id}/dns/{record_id}/resolve", post(dns::resolve))
.route(
"/domains/{id}/subdomains",
get(subdomains::list).post(subdomains::create),
)
.route(
"/subdomains/{id}",
get(subdomains::get_one)
.patch(subdomains::update)
.delete(subdomains::delete),
)
.route("/certificates", get(certificates::list))
.route("/certificates/check", post(certificates::check_all))
.route("/certificates/summary", get(certificates::summary))
.route("/certificates/{id}", get(certificates::get_one))
.route("/domains/{id}/sync", post(sync::sync_domain))
.route("/sync", post(sync::sync_all))
.route("/sync/jobs/{id}", get(sync::get_job))
.layer(middleware::from_fn_with_state(state.clone(), auth::require_auth));
let api = Router::new()
.route("/auth/login", post(auth::login))
.merge(protected);
Router::new()
.route("/health", get(health::health))
.route("/ready", get(health::ready))
.nest("/api/v1", api)
.fallback_service(static_service)
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
.with_state(state)
}
-212
View File
@@ -1,212 +0,0 @@
use crate::cloudflare::retry::{parse_retry_after, with_retry};
use crate::cloudflare::types::{
CfDnsRecord, CfResponse, CfZone, CreateDnsRecordPayload,
};
use crate::error::{AppError, AppResult};
use reqwest::Client;
use std::time::Duration;
const BASE_URL: &str = "https://api.cloudflare.com/client/v4";
#[derive(Clone)]
pub struct CloudflareClient {
http: Client,
token: String,
}
impl CloudflareClient {
pub fn new(token: impl Into<String>) -> Self {
Self {
http: Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("http client"),
token: token.into(),
}
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req.bearer_auth(&self.token)
}
async fn handle_response<T: serde::de::DeserializeOwned>(
&self,
response: reqwest::Response,
operation: &str,
) -> AppResult<T> {
let status = response.status();
let headers = response.headers().clone();
if status.as_u16() == 429 {
let wait = parse_retry_after(&headers).unwrap_or(Duration::from_secs(5));
tracing::warn!(operation, ?wait, "cloudflare rate limited");
return Err(AppError::Cloudflare(format!(
"rate limited, retry after {:?}",
wait
)));
}
let body: CfResponse<T> = response.json().await.map_err(|e| {
AppError::Cloudflare(format!("{operation}: invalid response: {e}"))
})?;
if !body.success {
let msg = body
.errors
.map(|errs| {
errs.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("; ")
})
.unwrap_or_else(|| "unknown cloudflare error".into());
return Err(AppError::Cloudflare(format!("{operation}: {msg}")));
}
body.result
.ok_or_else(|| AppError::Cloudflare(format!("{operation}: empty result")))
}
pub async fn list_zones(&self) -> AppResult<Vec<CfZone>> {
let token = self.token.clone();
let http = self.http.clone();
with_retry(|| {
let http = http.clone();
let token = token.clone();
async move {
let client = Self {
http: http.clone(),
token: token.clone(),
};
let mut all = Vec::new();
let mut page = 1u32;
loop {
let response = http
.get(format!("{BASE_URL}/zones"))
.bearer_auth(&token)
.query(&[("per_page", "50"), ("page", &page.to_string())])
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
if response.status().is_server_error() || response.status().as_u16() == 429 {
return Err(AppError::Cloudflare(response.status().to_string()));
}
let batch: Vec<CfZone> =
client.handle_response(response, "list_zones").await?;
if batch.is_empty() {
break;
}
let batch_len = batch.len();
all.extend(batch);
if batch_len < 50 {
break;
}
page += 1;
}
Ok(all)
}
})
.await
}
pub async fn get_zone(&self, zone_id: &str) -> AppResult<CfZone> {
let url = format!("{BASE_URL}/zones/{zone_id}");
let response = self
.auth(self.http.get(&url))
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
self.handle_response(response, "get_zone").await
}
pub async fn list_dns_records(&self, zone_id: &str) -> AppResult<Vec<CfDnsRecord>> {
let zone_id = zone_id.to_string();
let token = self.token.clone();
let http = self.http.clone();
with_retry(|| {
let http = http.clone();
let token = token.clone();
let zone_id = zone_id.clone();
async move {
let mut all = Vec::new();
let mut page = 1u32;
loop {
let response = http
.get(format!("{BASE_URL}/zones/{zone_id}/dns_records"))
.bearer_auth(&token)
.query(&[("per_page", "100"), ("page", &page.to_string())])
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
if response.status().is_server_error() || response.status().as_u16() == 429 {
return Err(AppError::Cloudflare(response.status().to_string()));
}
let client = Self {
http: http.clone(),
token: token.clone(),
};
let batch: Vec<CfDnsRecord> = client
.handle_response(response, "list_dns_records")
.await?;
if batch.is_empty() {
break;
}
all.extend(batch);
page += 1;
if page > 50 {
break;
}
}
Ok(all)
}
})
.await
}
pub async fn create_dns_record(
&self,
zone_id: &str,
payload: &CreateDnsRecordPayload,
) -> AppResult<CfDnsRecord> {
let url = format!("{BASE_URL}/zones/{zone_id}/dns_records");
let response = self
.auth(self.http.post(&url))
.json(payload)
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
self.handle_response(response, "create_dns_record").await
}
pub async fn update_dns_record(
&self,
zone_id: &str,
record_id: &str,
payload: &CreateDnsRecordPayload,
) -> AppResult<CfDnsRecord> {
let url = format!("{BASE_URL}/zones/{zone_id}/dns_records/{record_id}");
let response = self
.auth(self.http.put(&url))
.json(payload)
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
self.handle_response(response, "update_dns_record").await
}
pub async fn delete_dns_record(&self, zone_id: &str, record_id: &str) -> AppResult<()> {
let url = format!("{BASE_URL}/zones/{zone_id}/dns_records/{record_id}");
let response = self
.auth(self.http.delete(&url))
.send()
.await
.map_err(|e| AppError::Cloudflare(e.to_string()))?;
let _: CfResponse<serde_json::Value> =
self.handle_response(response, "delete_dns_record").await?;
Ok(())
}
}
-5
View File
@@ -1,5 +0,0 @@
pub mod client;
pub mod retry;
pub mod types;
pub use client::CloudflareClient;
-36
View File
@@ -1,36 +0,0 @@
use std::time::Duration;
use tokio::time::sleep;
pub async fn with_retry<F, Fut, T, E>(mut operation: F) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut delay = Duration::from_millis(500);
let mut last_err = None;
for attempt in 0..3 {
match operation().await {
Ok(value) => return Ok(value),
Err(err) => {
tracing::warn!(attempt = attempt + 1, error = %err, "cloudflare retry");
last_err = Some(err);
if attempt < 2 {
sleep(delay).await;
delay *= 2;
}
}
}
}
Err(last_err.expect("retry loop"))
}
pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
}
-44
View File
@@ -1,44 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfZone {
pub id: String,
pub name: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CfDnsRecord {
pub id: Option<String>,
#[serde(rename = "type")]
pub record_type: String,
pub name: String,
pub content: String,
pub ttl: i64,
pub proxied: Option<bool>,
pub priority: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateDnsRecordPayload {
#[serde(rename = "type")]
pub record_type: String,
pub name: String,
pub content: String,
pub ttl: i64,
pub proxied: Option<bool>,
pub priority: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CfResponse<T> {
pub success: bool,
pub result: Option<T>,
pub errors: Option<Vec<CfApiError>>,
}
#[derive(Debug, Deserialize)]
pub struct CfApiError {
pub code: i64,
pub message: String,
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub cloudflare_api_token: String,
pub jwt_secret: String,
pub jwt_ttl_hours: i64,
pub admin_username: String,
pub admin_password_hash: String,
pub server_port: u16,
pub static_dir: Option<PathBuf>,
pub cert_check_cron: String,
pub rust_log: String,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
Ok(Self {
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:data/app.db".into()),
cloudflare_api_token: std::env::var("CLOUDFLARE_API_TOKEN")
.unwrap_or_default()
.trim()
.to_string(),
jwt_secret: std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-change-me".into()),
jwt_ttl_hours: std::env::var("JWT_TTL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(24),
admin_username: std::env::var("ADMIN_USERNAME")
.unwrap_or_else(|_| "admin".into()),
admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "devplaceholder".into()),
server_port: std::env::var("SERVER_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080),
static_dir: std::env::var("STATIC_DIR").ok().map(PathBuf::from),
cert_check_cron: std::env::var("CERT_CHECK_CRON")
.unwrap_or_else(|_| "0 0 */6 * * *".into()),
rust_log: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
})
}
}
-210
View File
@@ -1,210 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Group {
pub id: i64,
pub name: String,
pub slug: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ServiceGroup {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupView {
pub id: i64,
pub name: String,
#[serde(rename = "type")]
pub group_type: String,
pub icon: Option<String>,
pub domain: Option<String>,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
pub services: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceGroupsResponse {
pub groups: Vec<ServiceGroupView>,
pub ungrouped: Vec<ServiceView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Service {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Domain {
pub id: i64,
pub group_id: Option<i64>,
pub zone_name: String,
pub cf_zone_id: String,
pub status: String,
pub last_synced_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Subdomain {
pub id: i64,
pub domain_id: i64,
pub name: String,
pub fqdn: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DnsRecord {
pub id: i64,
pub domain_id: i64,
pub cf_record_id: Option<String>,
pub record_type: String,
pub name: String,
pub content: String,
pub ttl: i64,
pub proxied: bool,
pub priority: Option<i64>,
pub sync_status: String,
pub origin: String,
pub last_error: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Certificate {
pub id: i64,
pub domain_id: i64,
pub subdomain_id: Option<i64>,
pub hostname: String,
pub expires_at: Option<String>,
pub last_checked_at: Option<String>,
pub last_error: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ServiceBinding {
pub id: i64,
pub domain_id: i64,
pub service_id: i64,
pub hostname: String,
pub dns_record_id: Option<i64>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ServiceBindingView {
pub id: i64,
pub domain_id: i64,
pub service_id: i64,
pub hostname: String,
pub dns_record_id: Option<i64>,
pub zone_name: String,
pub group_id: Option<i64>,
pub group_name: Option<String>,
pub service_name: String,
pub service_slug: String,
pub target_ip: Option<String>,
pub sync_status: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDomainBindingView {
pub binding_id: i64,
pub domain_id: i64,
pub zone_name: String,
pub hostname: String,
pub fqdn: String,
pub target_ips: Vec<String>,
pub sync_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceView {
pub id: i64,
pub name: String,
pub slug: String,
pub service_group_id: Option<i64>,
pub subdomain: String,
pub enabled: bool,
pub computed_fqdn: Option<String>,
pub created_at: String,
pub updated_at: String,
pub ips: Vec<String>,
pub domains: Vec<ServiceDomainBindingView>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupWithStats {
pub id: i64,
pub name: String,
pub slug: String,
pub created_at: String,
pub updated_at: String,
pub domain_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DomainListItem {
pub id: i64,
pub group_id: Option<i64>,
pub zone_name: String,
pub cf_zone_id: String,
pub status: String,
pub last_synced_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub group_name: Option<String>,
pub service_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SyncJob {
pub id: String,
pub status: String,
pub domain_id: Option<i64>,
pub message: Option<String>,
pub created_at: String,
pub finished_at: Option<String>,
}
pub const SYNC_SYNCED: &str = "synced";
pub const SYNC_PENDING_PUSH: &str = "pending_push";
pub const SYNC_PENDING_DELETE: &str = "pending_delete";
pub const SYNC_CONFLICT: &str = "conflict";
pub const SYNC_ERROR: &str = "error";
pub const CERT_OK: &str = "ok";
pub const CERT_WARNING: &str = "warning";
pub const CERT_EXPIRED: &str = "expired";
pub const CERT_ERROR: &str = "error";
pub const CERT_UNKNOWN: &str = "unknown";
-7
View File
@@ -1,7 +0,0 @@
pub mod entities;
pub mod subdomain;
pub mod validators;
pub use entities::*;
pub use subdomain::*;
pub use validators::*;
-99
View File
@@ -1,99 +0,0 @@
/// Преобразует имя DNS-записи Cloudflare в метку поддомена в зоне.
pub fn dns_name_to_subdomain_label(record_name: &str, zone_name: &str) -> Option<String> {
let record_name = record_name.trim().trim_end_matches('.');
let zone_name = zone_name.trim().trim_end_matches('.');
if record_name.is_empty() || zone_name.is_empty() {
return None;
}
if record_name == "*" {
return Some("*".to_string());
}
let wildcard_fqdn = format!("*.{zone_name}");
if record_name.eq_ignore_ascii_case(&wildcard_fqdn) {
return Some("*".to_string());
}
if record_name.eq_ignore_ascii_case(zone_name) {
return Some("@".to_string());
}
let zone_suffix = format!(".{zone_name}");
if record_name
.to_ascii_lowercase()
.ends_with(&zone_suffix.to_ascii_lowercase())
{
let prefix_len = record_name.len() - zone_suffix.len();
let prefix = &record_name[..prefix_len];
if prefix.is_empty() {
return Some("@".to_string());
}
return Some(prefix.to_string());
}
if !record_name.contains('.') {
return Some(record_name.to_string());
}
None
}
pub fn subdomain_label_to_fqdn(label: &str, zone_name: &str) -> String {
if label == "@" {
zone_name.to_string()
} else {
format!("{label}.{zone_name}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apex_record_maps_to_at() {
assert_eq!(
dns_name_to_subdomain_label("ivx.su", "ivx.su").as_deref(),
Some("@")
);
}
#[test]
fn www_maps_to_label() {
assert_eq!(
dns_name_to_subdomain_label("www.ivx.su", "ivx.su").as_deref(),
Some("www")
);
}
#[test]
fn nested_subdomain() {
assert_eq!(
dns_name_to_subdomain_label("api.staging.ivx.su", "ivx.su").as_deref(),
Some("api.staging")
);
}
#[test]
fn relative_name() {
assert_eq!(
dns_name_to_subdomain_label("mail", "ivx.su").as_deref(),
Some("mail")
);
}
#[test]
fn wildcard() {
assert_eq!(
dns_name_to_subdomain_label("*.ivx.su", "ivx.su").as_deref(),
Some("*")
);
}
#[test]
fn fqdn_from_label() {
assert_eq!(subdomain_label_to_fqdn("@", "ivx.su"), "ivx.su");
assert_eq!(subdomain_label_to_fqdn("www", "ivx.su"), "www.ivx.su");
}
}
-77
View File
@@ -1,77 +0,0 @@
use crate::error::{AppError, AppResult};
use regex::Regex;
use std::sync::LazyLock;
static NAME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$").unwrap());
static IPV4_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$").unwrap());
static IPV6_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$").unwrap());
const ALLOWED_TYPES: &[&str] = &["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"];
pub fn validate_dns_record(
record_type: &str,
name: &str,
content: &str,
ttl: i64,
proxied: bool,
) -> AppResult<()> {
let rt = record_type.to_uppercase();
if !ALLOWED_TYPES.contains(&rt.as_str()) {
return Err(AppError::Validation(format!("unsupported record type: {record_type}")));
}
if !NAME_RE.is_match(name) {
return Err(AppError::Validation(format!("invalid record name: {name}")));
}
if ttl != 1 && !(60..=86400).contains(&ttl) {
return Err(AppError::Validation("ttl must be 1 (auto) or 60-86400".into()));
}
if proxied && !matches!(rt.as_str(), "A" | "AAAA" | "CNAME") {
return Err(AppError::Validation("proxied only allowed for A, AAAA, CNAME".into()));
}
match rt.as_str() {
"A" if !IPV4_RE.is_match(content) => {
return Err(AppError::Validation("A record requires valid IPv4".into()));
}
"AAAA" if !IPV6_RE.is_match(content) => {
return Err(AppError::Validation("AAAA record requires valid IPv6".into()));
}
"CNAME" | "NS" if content.is_empty() || content.contains(' ') => {
return Err(AppError::Validation("CNAME/NS requires valid hostname".into()));
}
"TXT" if content.is_empty() || content.len() > 2048 => {
return Err(AppError::Validation("TXT content length 1-2048".into()));
}
_ => {}
}
Ok(())
}
pub fn cert_status_from_expiry(days_left: i64) -> &'static str {
if days_left < 0 {
crate::domain::CERT_EXPIRED
} else if days_left <= 30 {
crate::domain::CERT_WARNING
} else {
crate::domain::CERT_OK
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_a_record() {
assert!(validate_dns_record("A", "@", "192.168.1.1", 1, false).is_ok());
assert!(validate_dns_record("A", "@", "invalid", 1, false).is_err());
}
#[test]
fn cert_status_thresholds() {
assert_eq!(cert_status_from_expiry(60), crate::domain::CERT_OK);
assert_eq!(cert_status_from_expiry(10), crate::domain::CERT_WARNING);
assert_eq!(cert_status_from_expiry(-1), crate::domain::CERT_EXPIRED);
}
}
-77
View File
@@ -1,77 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("validation error: {0}")]
Validation(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("conflict: {0}")]
Conflict(String),
#[error("cloudflare error: {0}")]
Cloudflare(String),
#[error("internal error: {0}")]
Internal(String),
}
impl AppError {
pub fn code(&self) -> &'static str {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Validation(_) => "VALIDATION_ERROR",
Self::Unauthorized => "UNAUTHORIZED",
Self::Forbidden => "FORBIDDEN",
Self::Conflict(_) => "CONFLICT",
Self::Cloudflare(_) => "CLOUDFLARE_ERROR",
Self::Internal(_) => "INTERNAL_ERROR",
}
}
pub fn status(&self) -> StatusCode {
match self {
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Validation(_) => StatusCode::BAD_REQUEST,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Cloudflare(_) => StatusCode::BAD_GATEWAY,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let body = Json(json!({
"error": {
"code": self.code(),
"message": self.to_string(),
}
}));
(self.status(), body).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(value: sqlx::Error) -> Self {
Self::Internal(value.to_string())
}
}
impl From<serde_json::Error> for AppError {
fn from(value: serde_json::Error) -> Self {
Self::Internal(value.to_string())
}
}
pub type AppResult<T> = Result<T, AppError>;
-85
View File
@@ -1,85 +0,0 @@
mod api;
mod cloudflare;
mod config;
mod domain;
mod error;
mod repositories;
mod services;
mod state;
use api::create_router;
use config::Config;
use repositories::{create_pool, run_migrations};
use state::AppState;
use std::net::SocketAddr;
use tokio_cron_scheduler::{Job, JobScheduler};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| "failed to install rustls ring crypto provider")?;
load_dotenv();
let config = Config::from_env().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(&config.rust_log))
.init();
if config.cloudflare_api_token.is_empty() {
tracing::warn!("CLOUDFLARE_API_TOKEN не задан — импорт доменов из Cloudflare недоступен");
} else {
tracing::info!(
token_len = config.cloudflare_api_token.len(),
"CLOUDFLARE_API_TOKEN загружен"
);
}
let pool = create_pool(&config.database_url).await?;
run_migrations(&pool).await?;
let state = AppState::new(pool.clone(), config.clone());
start_cert_scheduler(pool, config.cert_check_cron.clone()).await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "certificate scheduler disabled");
});
let app = create_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.server_port));
tracing::info!("listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn load_dotenv() {
let manifest_env = concat!(env!("CARGO_MANIFEST_DIR"), "/../.env");
for path in [manifest_env, ".env", "../.env"] {
if dotenvy::from_filename(path).is_ok() {
return;
}
}
dotenvy::dotenv().ok();
}
async fn start_cert_scheduler(
pool: sqlx::SqlitePool,
cron: String,
) -> Result<(), Box<dyn std::error::Error>> {
let sched = JobScheduler::new().await?;
let pool_clone = pool.clone();
let job = Job::new_async(cron.as_str(), move |_uuid, _l| {
let pool = pool_clone.clone();
Box::pin(async move {
tracing::info!("certificate check started");
match services::certificate_service::run_all_checks(&pool).await {
Ok(n) => tracing::info!(checked = n, "certificate check completed"),
Err(e) => tracing::warn!(error = %e, "certificate check failed"),
}
})
})?;
sched.add(job).await?;
sched.start().await?;
Ok(())
}
-85
View File
@@ -1,85 +0,0 @@
use crate::domain::Certificate;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool, status: Option<&str>) -> AppResult<Vec<Certificate>> {
if let Some(s) = status {
Ok(sqlx::query_as::<_, Certificate>(
"SELECT * FROM certificates WHERE status = ? ORDER BY expires_at",
)
.bind(s)
.fetch_all(pool)
.await?)
} else {
Ok(sqlx::query_as::<_, Certificate>(
"SELECT * FROM certificates ORDER BY expires_at",
)
.fetch_all(pool)
.await?)
}
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Certificate> {
sqlx::query_as::<_, Certificate>("SELECT * FROM certificates WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("certificate {id}")))
}
pub async fn upsert_check(
pool: &SqlitePool,
domain_id: i64,
subdomain_id: Option<i64>,
hostname: &str,
expires_at: Option<&str>,
status: &str,
last_error: Option<&str>,
) -> AppResult<Certificate> {
let existing: Option<i64> = sqlx::query_scalar(
"SELECT id FROM certificates WHERE hostname = ?",
)
.bind(hostname)
.fetch_optional(pool)
.await?;
if let Some(id) = existing {
sqlx::query(
r#"UPDATE certificates SET domain_id = ?, subdomain_id = ?, expires_at = ?,
last_checked_at = datetime('now'), last_error = ?, status = ?, updated_at = datetime('now')
WHERE id = ?"#,
)
.bind(domain_id)
.bind(subdomain_id)
.bind(expires_at)
.bind(last_error)
.bind(status)
.bind(id)
.execute(pool)
.await?;
return get(pool, id).await;
}
let id = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO certificates (domain_id, subdomain_id, hostname, expires_at, last_checked_at, last_error, status)
VALUES (?, ?, ?, ?, datetime('now'), ?, ?) RETURNING id"#,
)
.bind(domain_id)
.bind(subdomain_id)
.bind(hostname)
.bind(expires_at)
.bind(last_error)
.bind(status)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn count_by_status(pool: &SqlitePool) -> AppResult<Vec<(String, i64)>> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT status, COUNT(*) FROM certificates GROUP BY status",
)
.fetch_all(pool)
.await?;
Ok(rows)
}
-203
View File
@@ -1,203 +0,0 @@
use crate::domain::{DnsRecord, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED};
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
#[derive(Debug, Clone, Default)]
pub struct DnsListFilter {
pub record_type: Option<String>,
pub name: Option<String>,
pub content: Option<String>,
pub proxied: Option<bool>,
pub sync_status: Option<String>,
pub q: Option<String>,
pub sort: String,
pub page: i64,
pub limit: i64,
}
pub async fn list(pool: &SqlitePool, domain_id: i64, filter: &DnsListFilter) -> AppResult<Vec<DnsRecord>> {
let mut sql = String::from(
"SELECT * FROM dns_records WHERE domain_id = ?",
);
let mut binds: Vec<String> = Vec::new();
if let Some(t) = &filter.record_type {
sql.push_str(" AND record_type = ?");
binds.push(t.to_uppercase());
}
if let Some(n) = &filter.name {
sql.push_str(" AND name LIKE ?");
binds.push(format!("%{n}%"));
}
if let Some(c) = &filter.content {
sql.push_str(" AND content LIKE ?");
binds.push(format!("%{c}%"));
}
if let Some(p) = filter.proxied {
sql.push_str(" AND proxied = ?");
binds.push(if p { "1".into() } else { "0".into() });
}
if let Some(s) = &filter.sync_status {
sql.push_str(" AND sync_status = ?");
binds.push(s.clone());
}
if let Some(q) = &filter.q {
sql.push_str(" AND (name LIKE ? OR content LIKE ? OR record_type LIKE ?)");
let pat = format!("%{q}%");
binds.push(pat.clone());
binds.push(pat.clone());
binds.push(pat);
}
let order = match filter.sort.as_str() {
"type" => "record_type",
"updated_at" => "updated_at",
_ => "name",
};
sql.push_str(&format!(" ORDER BY {order} ASC LIMIT ? OFFSET ?"));
let offset = (filter.page.max(1) - 1) * filter.limit.max(1);
let limit = filter.limit.max(1).min(200);
let mut query = sqlx::query_as::<_, DnsRecord>(&sql).bind(domain_id);
for b in &binds {
query = query.bind(b);
}
query = query.bind(limit).bind(offset);
Ok(query.fetch_all(pool).await?)
}
pub async fn get(pool: &SqlitePool, domain_id: i64, id: i64) -> AppResult<DnsRecord> {
sqlx::query_as::<_, DnsRecord>(
"SELECT * FROM dns_records WHERE id = ? AND domain_id = ?",
)
.bind(id)
.bind(domain_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("dns record {id}")))
}
pub async fn insert(
pool: &SqlitePool,
domain_id: i64,
record_type: &str,
name: &str,
content: &str,
ttl: i64,
proxied: bool,
priority: Option<i64>,
sync_status: &str,
origin: &str,
cf_record_id: Option<&str>,
) -> AppResult<DnsRecord> {
let id = sqlx::query_scalar::<_, i64>(
r#"INSERT INTO dns_records
(domain_id, cf_record_id, record_type, name, content, ttl, proxied, priority, sync_status, origin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"#,
)
.bind(domain_id)
.bind(cf_record_id)
.bind(record_type.to_uppercase())
.bind(name)
.bind(content)
.bind(ttl)
.bind(proxied)
.bind(priority)
.bind(sync_status)
.bind(origin)
.fetch_one(pool)
.await?;
get(pool, domain_id, id).await
}
pub async fn update_fields(
pool: &SqlitePool,
id: i64,
record_type: &str,
name: &str,
content: &str,
ttl: i64,
proxied: bool,
priority: Option<i64>,
sync_status: &str,
cf_record_id: Option<&str>,
last_error: Option<&str>,
) -> AppResult<()> {
sqlx::query(
r#"UPDATE dns_records SET
cf_record_id = ?, record_type = ?, name = ?, content = ?, ttl = ?, proxied = ?,
priority = ?, sync_status = ?, last_error = ?, updated_at = datetime('now')
WHERE id = ?"#,
)
.bind(cf_record_id)
.bind(record_type.to_uppercase())
.bind(name)
.bind(content)
.bind(ttl)
.bind(proxied)
.bind(priority)
.bind(sync_status)
.bind(last_error)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn set_sync_status(
pool: &SqlitePool,
id: i64,
sync_status: &str,
cf_record_id: Option<&str>,
last_error: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"UPDATE dns_records SET sync_status = ?, cf_record_id = ?, last_error = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(sync_status)
.bind(cf_record_id)
.bind(last_error)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
sqlx::query("DELETE FROM dns_records WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
"SELECT * FROM dns_records WHERE domain_id = ?",
)
.bind(domain_id)
.fetch_all(pool)
.await?)
}
pub async fn find_by_cf_id(
pool: &SqlitePool,
domain_id: i64,
cf_record_id: &str,
) -> AppResult<Option<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
"SELECT * FROM dns_records WHERE domain_id = ? AND cf_record_id = ?",
)
.bind(domain_id)
.bind(cf_record_id)
.fetch_optional(pool)
.await?)
}
pub async fn mark_pending_delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
set_sync_status(pool, id, SYNC_PENDING_DELETE, None, None).await
}
pub const SYNC_PUSH: &str = SYNC_PENDING_PUSH;
pub const SYNC_DONE: &str = SYNC_SYNCED;
-130
View File
@@ -1,130 +0,0 @@
use crate::domain::{Domain, DomainListItem};
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool, group_id: Option<i64>) -> AppResult<Vec<Domain>> {
if let Some(gid) = group_id {
Ok(sqlx::query_as::<_, Domain>(
"SELECT * FROM domains WHERE group_id = ? ORDER BY zone_name",
)
.bind(gid)
.fetch_all(pool)
.await?)
} else {
Ok(sqlx::query_as::<_, Domain>("SELECT * FROM domains ORDER BY zone_name")
.fetch_all(pool)
.await?)
}
}
const LIST_ENRICHED_SELECT: &str = r#"
SELECT
d.id,
d.group_id,
d.zone_name,
d.cf_zone_id,
d.status,
d.last_synced_at,
d.created_at,
d.updated_at,
g.name AS group_name,
(SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count
FROM domains d
LEFT JOIN groups g ON g.id = d.group_id
"#;
pub async fn list_enriched(pool: &SqlitePool, group_id: Option<i64>) -> AppResult<Vec<DomainListItem>> {
if let Some(gid) = group_id {
let sql = format!("{LIST_ENRICHED_SELECT} WHERE d.group_id = ? ORDER BY d.zone_name");
Ok(sqlx::query_as::<_, DomainListItem>(&sql)
.bind(gid)
.fetch_all(pool)
.await?)
} else {
let sql = format!("{LIST_ENRICHED_SELECT} ORDER BY d.zone_name");
Ok(sqlx::query_as::<_, DomainListItem>(&sql)
.fetch_all(pool)
.await?)
}
}
pub async fn find_by_zone_name(pool: &SqlitePool, zone_name: &str) -> AppResult<Option<Domain>> {
Ok(sqlx::query_as::<_, Domain>(
"SELECT * FROM domains WHERE LOWER(zone_name) = LOWER(?) LIMIT 1",
)
.bind(zone_name.trim())
.fetch_optional(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Domain> {
sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("domain {id}")))
}
pub async fn create(
pool: &SqlitePool,
group_id: Option<i64>,
zone_name: &str,
cf_zone_id: &str,
) -> AppResult<Domain> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO domains (group_id, zone_name, cf_zone_id) VALUES (?, ?, ?) RETURNING id",
)
.bind(group_id)
.bind(zone_name)
.bind(cf_zone_id)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(
pool: &SqlitePool,
id: i64,
group_id: Option<i64>,
status: &str,
) -> AppResult<Domain> {
let affected = sqlx::query(
"UPDATE domains SET group_id = ?, status = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(group_id)
.bind(status)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("domain {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM domains WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("domain {id}")));
}
Ok(())
}
pub async fn set_last_synced(pool: &SqlitePool, id: i64) -> AppResult<()> {
sqlx::query(
"UPDATE domains SET last_synced_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<Domain>> {
list(pool, None).await
}
-77
View File
@@ -1,77 +0,0 @@
use crate::domain::{Group, GroupWithStats};
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<Group>> {
let rows = sqlx::query_as::<_, Group>("SELECT * FROM groups ORDER BY name")
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Group> {
sqlx::query_as::<_, Group>("SELECT * FROM groups WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
}
pub async fn get_with_stats(pool: &SqlitePool, id: i64) -> AppResult<GroupWithStats> {
sqlx::query_as::<_, GroupWithStats>(
r#"
SELECT
g.id,
g.name,
g.slug,
g.created_at,
g.updated_at,
(SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count
FROM groups g
WHERE g.id = ?
"#,
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
}
pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Group> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO groups (name, slug) VALUES (?, ?) RETURNING id",
)
.bind(name)
.bind(slug)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult<Group> {
let affected = sqlx::query(
"UPDATE groups SET name = ?, slug = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(slug)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("group {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM groups WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("group {id}")));
}
Ok(())
}
-37
View File
@@ -1,37 +0,0 @@
pub mod certificates;
pub mod dns_records;
pub mod domains;
pub mod groups;
pub mod service_binding_ips;
pub mod service_binding_records;
pub mod service_bindings;
pub mod service_group_dns_records;
pub mod service_groups;
pub mod service_ips;
pub mod services;
pub mod subdomains;
pub mod sync_jobs;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use std::str::FromStr;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let url = database_url.strip_prefix("sqlite:").unwrap_or(database_url);
let options = SqliteConnectOptions::from_str(url)?
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(10))
.connect_with(options)
.await
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}
@@ -1,32 +0,0 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_binding_ips WHERE binding_id = ? ORDER BY ip",
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn replace_for_binding(
pool: &SqlitePool,
binding_id: i64,
ips: &[String],
) -> AppResult<()> {
sqlx::query("DELETE FROM service_binding_ips WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_binding_ips (binding_id, ip) VALUES (?, ?)")
.bind(binding_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
@@ -1,59 +0,0 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_binding(
pool: &SqlitePool,
binding_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_binding_records sbr
JOIN dns_records dr ON dr.id = sbr.dns_record_id
WHERE sbr.binding_id = ?
ORDER BY dr.content
"#,
)
.bind(binding_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_binding_records (binding_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, binding_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_binding_records WHERE binding_id = ? AND dns_record_id = ?",
)
.bind(binding_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink_all_for_binding(pool: &SqlitePool, binding_id: i64) -> AppResult<Vec<i64>> {
let ids = sqlx::query_scalar::<_, i64>(
"SELECT dns_record_id FROM service_binding_records WHERE binding_id = ?",
)
.bind(binding_id)
.fetch_all(pool)
.await?;
sqlx::query("DELETE FROM service_binding_records WHERE binding_id = ?")
.bind(binding_id)
.execute(pool)
.await?;
Ok(ids)
}
@@ -1,192 +0,0 @@
use crate::domain::{ServiceBinding, ServiceBindingView};
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
const VIEW_SELECT: &str = r#"
SELECT
sb.id,
sb.domain_id,
sb.service_id,
sb.hostname,
sb.dns_record_id,
d.zone_name,
d.group_id,
g.name AS group_name,
s.name AS service_name,
s.slug AS service_slug,
dr.content AS target_ip,
dr.sync_status,
sb.created_at,
sb.updated_at
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
JOIN services s ON s.id = sb.service_id
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
"#;
pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} ORDER BY d.zone_name, s.name");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
.fetch_all(pool)
.await?)
}
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.service_id = ? ORDER BY d.zone_name, sb.hostname");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
.bind(service_id)
.fetch_all(pool)
.await?)
}
pub async fn find_for_service_domain_hostname(
pool: &SqlitePool,
service_id: i64,
domain_id: i64,
hostname: &str,
) -> AppResult<Option<ServiceBinding>> {
Ok(sqlx::query_as::<_, ServiceBinding>(
r#"
SELECT * FROM service_bindings
WHERE service_id = ? AND domain_id = ? AND hostname = ?
"#,
)
.bind(service_id)
.bind(domain_id)
.bind(hostname)
.fetch_optional(pool)
.await?)
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<ServiceBindingView>> {
let sql = format!("{VIEW_SELECT} WHERE sb.domain_id = ? ORDER BY s.name");
Ok(sqlx::query_as::<_, ServiceBindingView>(&sql)
.bind(domain_id)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<ServiceBinding> {
sqlx::query_as::<_, ServiceBinding>("SELECT * FROM service_bindings WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service binding {id}")))
}
pub async fn get_view(pool: &SqlitePool, id: i64) -> AppResult<ServiceBindingView> {
let sql = format!("{VIEW_SELECT} WHERE sb.id = ?");
sqlx::query_as::<_, ServiceBindingView>(&sql)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service binding {id}")))
}
pub async fn insert(
pool: &SqlitePool,
domain_id: i64,
service_id: i64,
hostname: &str,
dns_record_id: Option<i64>,
) -> AppResult<ServiceBinding> {
let id = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO service_bindings (domain_id, service_id, hostname, dns_record_id)
VALUES (?, ?, ?, ?)
RETURNING id
"#,
)
.bind(domain_id)
.bind(service_id)
.bind(hostname)
.bind(dns_record_id)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update_fields(
pool: &SqlitePool,
id: i64,
service_id: i64,
hostname: &str,
dns_record_id: Option<i64>,
) -> AppResult<ServiceBinding> {
let affected = sqlx::query(
r#"
UPDATE service_bindings
SET service_id = ?, hostname = ?, dns_record_id = ?, updated_at = datetime('now')
WHERE id = ?
"#,
)
.bind(service_id)
.bind(hostname)
.bind(dns_record_id)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service binding {id}")));
}
get(pool, id).await
}
pub async fn set_dns_record_id(pool: &SqlitePool, id: i64, dns_record_id: Option<i64>) -> AppResult<()> {
let affected = sqlx::query(
"UPDATE service_bindings SET dns_record_id = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(dns_record_id)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service binding {id}")));
}
Ok(())
}
pub async fn bindings_to_remove(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<Vec<ServiceBinding>> {
let bindings = sqlx::query_as::<_, ServiceBinding>(
"SELECT * FROM service_bindings WHERE service_id = ?",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(bindings
.into_iter()
.filter(|binding| !keep_ids.contains(&binding.id))
.collect())
}
pub async fn delete_for_service_except(
pool: &SqlitePool,
service_id: i64,
keep_ids: &[i64],
) -> AppResult<()> {
let to_remove = bindings_to_remove(pool, service_id, keep_ids).await?;
for binding in to_remove {
delete(pool, binding.id).await?;
}
Ok(())
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_bindings WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service binding {id}")));
}
Ok(())
}
@@ -1,43 +0,0 @@
use crate::domain::DnsRecord;
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_records_for_group(
pool: &SqlitePool,
group_id: i64,
) -> AppResult<Vec<DnsRecord>> {
Ok(sqlx::query_as::<_, DnsRecord>(
r#"
SELECT dr.*
FROM service_group_dns_records sgdr
JOIN dns_records dr ON dr.id = sgdr.dns_record_id
WHERE sgdr.group_id = ?
ORDER BY dr.content
"#,
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn link(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"INSERT INTO service_group_dns_records (group_id, dns_record_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
pub async fn unlink(pool: &SqlitePool, group_id: i64, dns_record_id: i64) -> AppResult<()> {
sqlx::query(
"DELETE FROM service_group_dns_records WHERE group_id = ? AND dns_record_id = ?",
)
.bind(group_id)
.bind(dns_record_id)
.execute(pool)
.await?;
Ok(())
}
@@ -1,92 +0,0 @@
use crate::domain::ServiceGroup;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<ServiceGroup>> {
Ok(sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<ServiceGroup> {
sqlx::query_as::<_, ServiceGroup>(
"SELECT id, name, type AS group_type, icon, domain, enabled, created_at, updated_at FROM service_groups WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service group {id}")))
}
pub async fn create(
pool: &SqlitePool,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO service_groups (name, type, icon, domain) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(
pool: &SqlitePool,
id: i64,
name: &str,
group_type: &str,
icon: Option<&str>,
domain: Option<&str>,
) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET name = ?, type = ?, icon = ?, domain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(group_type)
.bind(icon)
.bind(domain)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<ServiceGroup> {
let affected = sqlx::query(
"UPDATE service_groups SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM service_groups WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service group {id}")));
}
Ok(())
}
-28
View File
@@ -1,28 +0,0 @@
use crate::error::AppResult;
use sqlx::SqlitePool;
pub async fn list_by_service(pool: &SqlitePool, service_id: i64) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT ip FROM service_ips WHERE service_id = ? ORDER BY ip",
)
.bind(service_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn replace_for_service(pool: &SqlitePool, service_id: i64, ips: &[String]) -> AppResult<()> {
sqlx::query("DELETE FROM service_ips WHERE service_id = ?")
.bind(service_id)
.execute(pool)
.await?;
for ip in ips {
sqlx::query("INSERT INTO service_ips (service_id, ip) VALUES (?, ?)")
.bind(service_id)
.bind(ip)
.execute(pool)
.await?;
}
Ok(())
}
-105
View File
@@ -1,105 +0,0 @@
use crate::domain::Service;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list(pool: &SqlitePool) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>("SELECT * FROM services ORDER BY name")
.fetch_all(pool)
.await?)
}
pub async fn list_by_group(pool: &SqlitePool, group_id: i64) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id = ? ORDER BY name",
)
.bind(group_id)
.fetch_all(pool)
.await?)
}
pub async fn list_ungrouped(pool: &SqlitePool) -> AppResult<Vec<Service>> {
Ok(sqlx::query_as::<_, Service>(
"SELECT * FROM services WHERE service_group_id IS NULL ORDER BY name",
)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Service> {
sqlx::query_as::<_, Service>("SELECT * FROM services WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("service {id}")))
}
pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult<Service> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO services (name, slug, subdomain) VALUES (?, ?, ?) RETURNING id",
)
.bind(name)
.bind(slug)
.bind(slug)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET name = ?, slug = ?, subdomain = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(slug)
.bind(slug)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(enabled)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn set_group(pool: &SqlitePool, id: i64, group_id: Option<i64>) -> AppResult<Service> {
let affected = sqlx::query(
"UPDATE services SET service_group_id = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(group_id)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM services WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("service {id}")));
}
Ok(())
}
-92
View File
@@ -1,92 +0,0 @@
use crate::domain::Subdomain;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<Subdomain>> {
Ok(sqlx::query_as::<_, Subdomain>(
"SELECT * FROM subdomains WHERE domain_id = ? ORDER BY name",
)
.bind(domain_id)
.fetch_all(pool)
.await?)
}
pub async fn get(pool: &SqlitePool, id: i64) -> AppResult<Subdomain> {
sqlx::query_as::<_, Subdomain>("SELECT * FROM subdomains WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("subdomain {id}")))
}
pub async fn upsert(
pool: &SqlitePool,
domain_id: i64,
name: &str,
fqdn: &str,
) -> AppResult<()> {
sqlx::query(
r#"INSERT INTO subdomains (domain_id, name, fqdn)
VALUES (?, ?, ?)
ON CONFLICT(domain_id, name) DO UPDATE SET
fqdn = excluded.fqdn,
updated_at = datetime('now')"#,
)
.bind(domain_id)
.bind(name)
.bind(fqdn)
.execute(pool)
.await?;
Ok(())
}
pub async fn create(
pool: &SqlitePool,
domain_id: i64,
name: &str,
fqdn: &str,
) -> AppResult<Subdomain> {
let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO subdomains (domain_id, name, fqdn) VALUES (?, ?, ?) RETURNING id",
)
.bind(domain_id)
.bind(name)
.bind(fqdn)
.fetch_one(pool)
.await?;
get(pool, id).await
}
pub async fn update(pool: &SqlitePool, id: i64, name: &str, fqdn: &str) -> AppResult<Subdomain> {
let affected = sqlx::query(
"UPDATE subdomains SET name = ?, fqdn = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(name)
.bind(fqdn)
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("subdomain {id}")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
let affected = sqlx::query("DELETE FROM subdomains WHERE id = ?")
.bind(id)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!("subdomain {id}")));
}
Ok(())
}
pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<Subdomain>> {
Ok(sqlx::query_as::<_, Subdomain>("SELECT * FROM subdomains ORDER BY fqdn")
.fetch_all(pool)
.await?)
}
-43
View File
@@ -1,43 +0,0 @@
use crate::domain::SyncJob;
use crate::error::{AppError, AppResult};
use sqlx::SqlitePool;
pub async fn create(
pool: &SqlitePool,
id: &str,
domain_id: Option<i64>,
) -> AppResult<SyncJob> {
sqlx::query(
"INSERT INTO sync_jobs (id, domain_id, status) VALUES (?, ?, 'pending')",
)
.bind(id)
.bind(domain_id)
.execute(pool)
.await?;
get(pool, id).await
}
pub async fn get(pool: &SqlitePool, id: &str) -> AppResult<SyncJob> {
sqlx::query_as::<_, SyncJob>("SELECT * FROM sync_jobs WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("sync job {id}")))
}
pub async fn finish(
pool: &SqlitePool,
id: &str,
status: &str,
message: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"UPDATE sync_jobs SET status = ?, message = ?, finished_at = datetime('now') WHERE id = ?",
)
.bind(status)
.bind(message)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
-71
View File
@@ -1,71 +0,0 @@
use crate::config::Config;
use crate::error::{AppError, AppResult};
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: i64,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub expires_at: String,
}
pub fn verify_password(config: &Config, password: &str) -> AppResult<()> {
if config.admin_password_hash == "devplaceholder" {
if password == "admin" {
return Ok(());
}
return Err(AppError::Unauthorized);
}
let parsed = PasswordHash::new(&config.admin_password_hash)
.map_err(|e| AppError::Internal(e.to_string()))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AppError::Unauthorized)?;
Ok(())
}
pub fn login(config: &Config, req: LoginRequest) -> AppResult<LoginResponse> {
if req.username != config.admin_username {
return Err(AppError::Unauthorized);
}
verify_password(config, &req.password)?;
let exp = Utc::now() + Duration::hours(config.jwt_ttl_hours);
let claims = Claims {
sub: req.username.clone(),
exp: exp.timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(LoginResponse {
token,
expires_at: exp.to_rfc3339(),
})
}
pub fn validate_token(config: &Config, token: &str) -> AppResult<Claims> {
decode::<Claims>(
token,
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
&Validation::default(),
)
.map(|d| d.claims)
.map_err(|_| AppError::Unauthorized)
}
-175
View File
@@ -1,175 +0,0 @@
use crate::cloudflare::CloudflareClient;
use crate::domain::ServiceBindingView;
use crate::error::AppResult;
use crate::repositories::{domains, service_bindings, services as service_repo};
use crate::services::dns_service::{self, CreateDnsRequest, UpdateDnsRequest};
use serde::Deserialize;
use sqlx::SqlitePool;
#[derive(Debug, Deserialize)]
pub struct CreateBindingRequest {
pub domain_id: i64,
pub service_id: i64,
pub hostname: Option<String>,
pub target_ip: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateBindingRequest {
pub service_id: Option<i64>,
pub hostname: Option<String>,
pub target_ip: Option<String>,
}
fn normalize_hostname(hostname: Option<String>) -> String {
hostname
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "@".to_string())
}
async fn sync_target_ip(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
binding_id: i64,
hostname: &str,
dns_record_id: Option<i64>,
target_ip: &str,
) -> AppResult<i64> {
if let Some(record_id) = dns_record_id {
dns_service::update(
pool,
cf,
domain_id,
record_id,
UpdateDnsRequest {
record_type: Some("A".into()),
name: Some(hostname.to_string()),
content: Some(target_ip.to_string()),
ttl: None,
proxied: Some(false),
priority: None,
},
)
.await?;
Ok(record_id)
} else {
let record = dns_service::create(
pool,
cf,
domain_id,
CreateDnsRequest {
record_type: "A".into(),
name: hostname.to_string(),
content: target_ip.to_string(),
ttl: Some(1),
proxied: Some(false),
priority: None,
},
)
.await?;
service_bindings::set_dns_record_id(pool, binding_id, Some(record.id)).await?;
Ok(record.id)
}
}
pub async fn list_all(pool: &SqlitePool) -> AppResult<Vec<ServiceBindingView>> {
service_bindings::list_all(pool).await
}
pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult<Vec<ServiceBindingView>> {
domains::get(pool, domain_id).await?;
service_bindings::list_by_domain(pool, domain_id).await
}
pub async fn create(
pool: &SqlitePool,
cf: &CloudflareClient,
req: CreateBindingRequest,
) -> AppResult<ServiceBindingView> {
domains::get(pool, req.domain_id).await?;
service_repo::get(pool, req.service_id).await?;
let hostname = normalize_hostname(req.hostname);
let binding = service_bindings::insert(pool, req.domain_id, req.service_id, &hostname, None).await?;
if let Some(ip) = req.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
sync_target_ip(pool, cf, req.domain_id, binding.id, &hostname, None, ip).await?;
}
service_bindings::get_view(pool, binding.id).await
}
pub async fn update(
pool: &SqlitePool,
cf: &CloudflareClient,
id: i64,
req: UpdateBindingRequest,
) -> AppResult<ServiceBindingView> {
let existing = service_bindings::get(pool, id).await?;
let service_id = if let Some(sid) = req.service_id {
service_repo::get(pool, sid).await?;
sid
} else {
existing.service_id
};
let hostname = req
.hostname
.map(|h| normalize_hostname(Some(h)))
.unwrap_or_else(|| existing.hostname.clone());
let dns_record_id = existing.dns_record_id;
service_bindings::update_fields(pool, id, service_id, &hostname, dns_record_id).await?;
if let Some(ip) = req.target_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
sync_target_ip(
pool,
cf,
existing.domain_id,
id,
&hostname,
dns_record_id,
ip,
)
.await?;
}
service_bindings::get_view(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> {
service_bindings::get(pool, id).await?;
service_bindings::delete(pool, id).await
}
pub async fn set_domain_services(
pool: &SqlitePool,
domain_id: i64,
service_ids: Vec<i64>,
) -> AppResult<Vec<i64>> {
domains::get(pool, domain_id).await?;
for sid in &service_ids {
service_repo::get(pool, *sid).await?;
}
let existing = service_bindings::list_by_domain(pool, domain_id).await?;
for binding in &existing {
if !service_ids.contains(&binding.service_id) {
service_bindings::delete(pool, binding.id).await?;
}
}
for sid in &service_ids {
let already = existing.iter().any(|b| b.service_id == *sid);
if !already {
service_bindings::insert(pool, domain_id, *sid, "@", None).await?;
}
}
Ok(service_bindings::list_by_domain(pool, domain_id)
.await?
.into_iter()
.map(|b| b.service_id)
.collect())
}
-138
View File
@@ -1,138 +0,0 @@
use crate::domain::{cert_status_from_expiry, Certificate, CERT_ERROR, CERT_UNKNOWN};
use crate::error::AppResult;
use crate::repositories::{certificates, domains, subdomains};
use chrono::Utc;
use sqlx::SqlitePool;
use rustls::{ClientConfig, RootCertStore};
use rustls::pki_types::ServerName;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration as TokioDuration};
use tokio_rustls::TlsConnector;
use x509_parser::prelude::FromDer;
pub async fn list_certificates(pool: &SqlitePool, status: Option<&str>) -> AppResult<Vec<Certificate>> {
certificates::list(pool, status).await
}
pub async fn get_certificate(pool: &SqlitePool, id: i64) -> AppResult<Certificate> {
certificates::get(pool, id).await
}
pub async fn check_hostname(hostname: &str) -> (Option<chrono::DateTime<Utc>>, Option<String>) {
let addr = match format!("{hostname}:443").to_socket_addrs() {
Ok(mut addrs) => match addrs.next() {
Some(a) => a,
None => return (None, Some("cannot resolve host".into())),
},
Err(e) => return (None, Some(e.to_string())),
};
let stream = match timeout(TokioDuration::from_secs(10), TcpStream::connect(addr)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return (None, Some(e.to_string())),
Err(_) => return (None, Some("connection timeout".into())),
};
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let server_name = match ServerName::try_from(hostname.to_string()) {
Ok(n) => n,
Err(e) => return (None, Some(e.to_string())),
};
let tls = match connector.connect(server_name, stream).await {
Ok(s) => s,
Err(e) => return (None, Some(e.to_string())),
};
let (_, session) = tls.into_inner();
let certs = session.peer_certificates();
let Some(chain) = certs else {
return (None, Some("no peer certificates".into()));
};
let Some(leaf) = chain.first() else {
return (None, Some("empty cert chain".into()));
};
match x509_parser::certificate::X509Certificate::from_der(leaf.as_ref()) {
Ok((_, cert)) => {
let not_after = cert.validity().not_after.timestamp();
let expires = chrono::DateTime::from_timestamp(not_after, 0);
(expires, None)
}
Err(e) => (None, Some(e.to_string())),
}
}
pub async fn check_and_store(
pool: &SqlitePool,
domain_id: i64,
subdomain_id: Option<i64>,
hostname: &str,
) -> AppResult<Certificate> {
let (expires_at, err) = check_hostname(hostname).await;
let status = if let Some(err_msg) = &err {
certificates::upsert_check(
pool,
domain_id,
subdomain_id,
hostname,
expires_at.map(|e| e.to_rfc3339()).as_deref(),
CERT_ERROR,
Some(err_msg),
)
.await?
} else if let Some(exp) = expires_at {
let days = (exp - Utc::now()).num_days();
let st = cert_status_from_expiry(days);
certificates::upsert_check(
pool,
domain_id,
subdomain_id,
hostname,
Some(&exp.to_rfc3339()),
st,
None,
)
.await?
} else {
certificates::upsert_check(
pool,
domain_id,
subdomain_id,
hostname,
None,
CERT_UNKNOWN,
Some("unknown expiry"),
)
.await?
};
Ok(status)
}
pub async fn run_all_checks(pool: &SqlitePool) -> AppResult<usize> {
let mut count = 0usize;
let all_domains = domains::list_all(pool).await?;
for domain in all_domains {
check_and_store(pool, domain.id, None, &domain.zone_name).await?;
count += 1;
}
let subs = subdomains::list_all(pool).await?;
for sub in subs {
check_and_store(pool, sub.domain_id, Some(sub.id), &sub.fqdn).await?;
count += 1;
}
Ok(count)
}
pub async fn status_summary(pool: &SqlitePool) -> AppResult<Vec<(String, i64)>> {
certificates::count_by_status(pool).await
}
-320
View File
@@ -1,320 +0,0 @@
use crate::cloudflare::types::CreateDnsRecordPayload;
use crate::cloudflare::CloudflareClient;
use crate::domain::{DnsRecord, Domain, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED};
use crate::domain::validate_dns_record;
use crate::error::{AppError, AppResult};
use crate::repositories::{dns_records, domains};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Debug, Deserialize)]
pub struct CreateDnsRequest {
pub record_type: String,
pub name: String,
pub content: String,
pub ttl: Option<i64>,
pub proxied: Option<bool>,
pub priority: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateDnsRequest {
pub record_type: Option<String>,
pub name: Option<String>,
pub content: Option<String>,
pub ttl: Option<i64>,
pub proxied: Option<bool>,
pub priority: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct BulkDnsOp {
pub action: String,
pub id: Option<i64>,
pub record: Option<CreateDnsRequest>,
}
#[derive(Debug, Serialize)]
pub struct BulkDnsResult {
pub id: Option<i64>,
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResolveDnsRequest {
pub source: String,
}
fn to_cf_payload(
record_type: &str,
name: &str,
content: &str,
ttl: i64,
proxied: bool,
priority: Option<i64>,
) -> CreateDnsRecordPayload {
CreateDnsRecordPayload {
record_type: record_type.to_uppercase(),
name: name.to_string(),
content: content.to_string(),
ttl,
proxied: Some(proxied),
priority,
}
}
async fn push_record(
pool: &SqlitePool,
cf: &CloudflareClient,
domain: &Domain,
record: &DnsRecord,
) -> AppResult<DnsRecord> {
let payload = to_cf_payload(
&record.record_type,
&record.name,
&record.content,
record.ttl,
record.proxied,
record.priority,
);
let result = if let Some(cf_id) = &record.cf_record_id {
cf.update_dns_record(&domain.cf_zone_id, cf_id, &payload).await
} else {
cf.create_dns_record(&domain.cf_zone_id, &payload).await
};
match result {
Ok(cf_rec) => {
let cf_id = cf_rec.id.as_deref();
dns_records::update_fields(
pool,
record.id,
&record.record_type,
&record.name,
&record.content,
record.ttl,
record.proxied,
record.priority,
SYNC_SYNCED,
cf_id,
None,
)
.await?;
dns_records::get(pool, domain.id, record.id).await
}
Err(e) => {
dns_records::set_sync_status(pool, record.id, SYNC_ERROR, record.cf_record_id.as_deref(), Some(&e.to_string())).await?;
Err(e)
}
}
}
pub async fn create(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
req: CreateDnsRequest,
) -> AppResult<DnsRecord> {
let domain = domains::get(pool, domain_id).await?;
let ttl = req.ttl.unwrap_or(1);
let proxied = req.proxied.unwrap_or(false);
validate_dns_record(&req.record_type, &req.name, &req.content, ttl, proxied)?;
let record = dns_records::insert(
pool,
domain_id,
&req.record_type,
&req.name,
&req.content,
ttl,
proxied,
req.priority,
SYNC_PENDING_PUSH,
"local",
None,
)
.await?;
push_record(pool, cf, &domain, &record).await
}
pub async fn update(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
record_id: i64,
req: UpdateDnsRequest,
) -> AppResult<DnsRecord> {
let domain = domains::get(pool, domain_id).await?;
let existing = dns_records::get(pool, domain_id, record_id).await?;
let record_type = req.record_type.unwrap_or(existing.record_type);
let name = req.name.unwrap_or(existing.name);
let content = req.content.unwrap_or(existing.content);
let ttl = req.ttl.unwrap_or(existing.ttl);
let proxied = req.proxied.unwrap_or(existing.proxied);
let priority = req.priority.or(existing.priority);
validate_dns_record(&record_type, &name, &content, ttl, proxied)?;
dns_records::update_fields(
pool,
record_id,
&record_type,
&name,
&content,
ttl,
proxied,
priority,
SYNC_PENDING_PUSH,
existing.cf_record_id.as_deref(),
None,
)
.await?;
let updated = dns_records::get(pool, domain_id, record_id).await?;
push_record(pool, cf, &domain, &updated).await
}
pub async fn delete_record(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
record_id: i64,
) -> AppResult<()> {
let domain = domains::get(pool, domain_id).await?;
let record = dns_records::get(pool, domain_id, record_id).await?;
dns_records::mark_pending_delete(pool, record_id).await?;
if let Some(cf_id) = &record.cf_record_id {
if let Err(e) = cf.delete_dns_record(&domain.cf_zone_id, cf_id).await {
dns_records::set_sync_status(pool, record_id, SYNC_ERROR, Some(cf_id), Some(&e.to_string())).await?;
return Err(e);
}
}
dns_records::delete(pool, record_id).await
}
pub async fn list(
pool: &SqlitePool,
domain_id: i64,
filter: dns_records::DnsListFilter,
) -> AppResult<Vec<DnsRecord>> {
domains::get(pool, domain_id).await?;
dns_records::list(pool, domain_id, &filter).await
}
pub async fn get(pool: &SqlitePool, domain_id: i64, record_id: i64) -> AppResult<DnsRecord> {
dns_records::get(pool, domain_id, record_id).await
}
pub async fn bulk(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
ops: Vec<BulkDnsOp>,
) -> AppResult<Vec<BulkDnsResult>> {
let mut results = Vec::new();
for op in ops {
let res = match op.action.as_str() {
"create" => {
let record = op.record.ok_or_else(|| AppError::Validation("record required".into()))?;
create(pool, cf, domain_id, record)
.await
.map(|r| BulkDnsResult { id: Some(r.id), success: true, error: None })
.unwrap_or_else(|e| BulkDnsResult {
id: None,
success: false,
error: Some(e.to_string()),
})
}
"update" => {
let id = op.id.ok_or_else(|| AppError::Validation("id required".into()))?;
let record = op.record.ok_or_else(|| AppError::Validation("record required".into()))?;
let update_req = UpdateDnsRequest {
record_type: Some(record.record_type),
name: Some(record.name),
content: Some(record.content),
ttl: record.ttl,
proxied: record.proxied,
priority: record.priority,
};
update(pool, cf, domain_id, id, update_req)
.await
.map(|_| BulkDnsResult { id: Some(id), success: true, error: None })
.unwrap_or_else(|e| BulkDnsResult {
id: Some(id),
success: false,
error: Some(e.to_string()),
})
}
"delete" => {
let id = op.id.ok_or_else(|| AppError::Validation("id required".into()))?;
delete_record(pool, cf, domain_id, id)
.await
.map(|_| BulkDnsResult { id: Some(id), success: true, error: None })
.unwrap_or_else(|e| BulkDnsResult {
id: Some(id),
success: false,
error: Some(e.to_string()),
})
}
other => BulkDnsResult {
id: op.id,
success: false,
error: Some(format!("unknown action: {other}")),
},
};
results.push(res);
}
Ok(results)
}
pub async fn resolve_conflict(
pool: &SqlitePool,
cf: &CloudflareClient,
domain_id: i64,
record_id: i64,
req: ResolveDnsRequest,
) -> AppResult<DnsRecord> {
let domain = domains::get(pool, domain_id).await?;
let record = dns_records::get(pool, domain_id, record_id).await?;
if record.sync_status != SYNC_CONFLICT {
return Err(AppError::Validation("record is not in conflict state".into()));
}
match req.source.as_str() {
"cloudflare" => {
if let Some(cf_id) = &record.cf_record_id {
let remote = cf
.list_dns_records(&domain.cf_zone_id)
.await?
.into_iter()
.find(|r| r.id.as_deref() == Some(cf_id.as_str()));
if let Some(r) = remote {
dns_records::update_fields(
pool,
record_id,
&r.record_type,
&r.name,
&r.content,
r.ttl,
r.proxied.unwrap_or(false),
r.priority,
SYNC_SYNCED,
r.id.as_deref(),
None,
)
.await?;
}
}
dns_records::get(pool, domain_id, record_id).await
}
"local" => {
let updated = dns_records::get(pool, domain_id, record_id).await?;
push_record(pool, cf, &domain, &updated).await
}
_ => Err(AppError::Validation("source must be cloudflare or local".into())),
}
}

Some files were not shown because too many files have changed in this diff Show More