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
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:
@@ -1,46 +1,37 @@
|
|||||||
---
|
---
|
||||||
description: Backend API — при изменениях, затрагивающих UI, строго следовать shadcn Components/Blocks
|
description: Backend API — при изменениях, затрагивающих UI, строго следовать shadcn Components/Blocks
|
||||||
globs: backend/**/*
|
globs: apps/api/**/*,packages/shared/**/*
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend API + shadcn/ui
|
# Backend API + shadcn/ui
|
||||||
|
|
||||||
Rust backend: `backend/src/` (Axum, sqlx). Frontend потребляет API через TanStack Query.
|
Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Frontend — TanStack Query.
|
||||||
|
|
||||||
## Когда правило активно
|
|
||||||
|
|
||||||
Любое изменение в `backend/src/api/handlers/`, DTO, полей ответа, которые отображаются в UI.
|
|
||||||
|
|
||||||
## Обязательный порядок
|
## Обязательный порядок
|
||||||
|
|
||||||
1. **Backend** — handler, валидация, тесты API
|
1. **Backend** — route, service, Vitest (`app.inject()`)
|
||||||
2. **Схемы frontend** — `apps/web/src/lib/schemas.ts`, `apps/web/src/queries/index.ts`
|
2. **Схемы** — `@cfdm/shared` (не дублировать в `apps/web/src/lib/schemas.ts`)
|
||||||
3. **UI** — **только** [shadcn Components](https://ui.shadcn.com/docs/components) и [Blocks](https://ui.shadcn.com/blocks)
|
3. **UI** — shadcn MCP ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
|
||||||
|
|
||||||
## Запрещено на frontend при доработке API
|
## Запрещено на frontend при доработке API
|
||||||
|
|
||||||
- Новые raw `<table>` / `<select>` / кастомные badge-цвета
|
- Новые raw `<table>` / `<select>` / кастомные badge-цвета
|
||||||
- Кастомный CSS для отображения новых полей
|
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
|
||||||
- Самописные формы без `Field` + RHF + Zod
|
- Самописные формы без `Field` + RHF + Zod
|
||||||
|
|
||||||
## Рекомендуемые shadcn-паттерны для типовых API
|
## shadcn-паттерны
|
||||||
|
|
||||||
Перед выбором паттерна — **MCP** `search_items_in_registries` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)).
|
| API-данные | UI |
|
||||||
|
|------------|-----|
|
||||||
| API-данные | UI (из docs) |
|
| Список | `Table` / `DataTableCard` |
|
||||||
|------------|--------------|
|
| Создание | `Card` + `FieldGroup` + RHF |
|
||||||
| Список сущностей | `Table` в `DataTableCard` или `data-table` block |
|
| Статус | `Badge` variants |
|
||||||
| Создание записи | `Card` + `FieldGroup` + RHF |
|
| Ошибка | `sonner` `toast.error` |
|
||||||
| Enum/фильтр | `Select` |
|
|
||||||
| Статус | `StatusBadge` → shadcn `Badge` variants |
|
|
||||||
| Ошибка мутации | `sonner` `toast.error` |
|
|
||||||
| Пустой список | `Empty` |
|
|
||||||
| Сводка/метрики | `Card` section-cards ([dashboard-01](https://ui.shadcn.com/blocks)) |
|
|
||||||
|
|
||||||
## Согласованность
|
## Согласованность
|
||||||
|
|
||||||
- Имена полей JSON — camelCase или snake_case как в существующем API; типы в Zod должны совпадать
|
- JSON поля — snake_case как в существующем API
|
||||||
- Новый endpoint → `queryOptions` factory в `apps/web/src/queries/`, не inline в route
|
- Новый 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)
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -12,11 +12,12 @@ alwaysApply: false
|
|||||||
|
|
||||||
```
|
```
|
||||||
apps/web/ # Vite SPA (routes, queries, domain components)
|
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
|
## Два components.json
|
||||||
|
|
||||||
| Файл | Назначение |
|
| Файл | Назначение |
|
||||||
|
|||||||
@@ -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"]
|
globs: ["**/*.rs", "Cargo.toml", "Cargo.lock"]
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rust General Rules
|
# Rust General Rules (deprecated)
|
||||||
|
|
||||||
## Project Structure
|
Проект использует **TypeScript + Fastify** (`apps/api`). Это правило не применяется.
|
||||||
|
|
||||||
- Keep crates focused and name modules by domain responsibility.
|
См. [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc).
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ UI строится **исключительно** по [shadcn/ui](https://ui.s
|
|||||||
|
|
||||||
## Backend → UI
|
## 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
@@ -40,13 +40,15 @@ Configure for **main** and **develop**:
|
|||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
cp .env.example .env
|
||||||
cd backend
|
|
||||||
cp ../.env.example ../.env
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# Frontend (separate terminal)
|
|
||||||
pnpm install
|
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
|
pnpm --filter web dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+22
-20
@@ -1,36 +1,38 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS frontend-builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
COPY packages/ui/package.json packages/ui/
|
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
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY apps/web apps/web
|
COPY apps apps
|
||||||
COPY packages/ui packages/ui
|
COPY packages packages
|
||||||
RUN pnpm --filter web build
|
COPY VERSION VERSION
|
||||||
|
RUN pnpm turbo build --filter=web --filter=@cfdm/api
|
||||||
|
|
||||||
FROM rust:1.85-bookworm AS backend-builder
|
FROM node:22-bookworm-slim AS runtime
|
||||||
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
|
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend-builder /app/backend/target/release/cfdm-backend /app/cfdm-backend
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=frontend-builder /app/apps/web/dist /app/static
|
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||||
COPY VERSION /app/VERSION
|
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 STATIC_DIR=/app/static
|
||||||
ENV DATABASE_URL=sqlite:/data/app.db
|
ENV DATABASE_URL=sqlite:/data/app.db
|
||||||
ENV SERVER_PORT=8080
|
ENV SERVER_PORT=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
VOLUME ["/data"]
|
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
@@ -1,26 +1,16 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM rust:1.85-bookworm AS test
|
FROM node:22-bookworm-slim AS test
|
||||||
WORKDIR /app
|
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
|
RUN corepack enable
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
COPY backend/Cargo.toml backend/Cargo.lock* ./backend/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY backend/migrations ./backend/migrations/
|
COPY apps/api/package.json apps/api/
|
||||||
COPY backend/src ./backend/src/
|
COPY packages/ui/package.json packages/ui/
|
||||||
WORKDIR /app/backend
|
COPY packages/shared/package.json packages/shared/
|
||||||
RUN cargo test --release
|
COPY packages/db/package.json packages/db/
|
||||||
|
|
||||||
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
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY apps/web /app/apps/web
|
COPY apps apps
|
||||||
COPY packages/ui /app/packages/ui
|
COPY packages packages
|
||||||
RUN pnpm --filter web test
|
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"]
|
||||||
CMD ["sh", "-c", "cd /app/backend && cargo test && cd /app && pnpm --filter web test"]
|
|
||||||
|
|||||||
@@ -23,16 +23,28 @@ Open http://localhost:8080 — default login `admin` / `admin` (dev only).
|
|||||||
|
|
||||||
## Stack
|
## 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
|
- **Frontend:** pnpm monorepo (`apps/web` + `packages/ui`), React, Vite, TanStack Router/Query/Table, shadcn/ui (base-nova), Recharts
|
||||||
- **CI:** Gitea Actions (Gitflow)
|
- **CI:** Gitea Actions (Gitflow)
|
||||||
|
|
||||||
## Frontend development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm --filter web dev # http://localhost:5173, proxies /api → :8080
|
pnpm --filter @cfdm/shared build
|
||||||
pnpm --filter web 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>`
|
shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add <component>`
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export { }
|
||||||
Vendored
+1999
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
validateDnsRecord,
|
||||||
|
certStatusFromExpiry,
|
||||||
|
isValidIpv4,
|
||||||
|
ValidationError,
|
||||||
|
} from "@cfdm/shared";
|
||||||
@@ -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" });
|
||||||
@@ -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" });
|
||||||
@@ -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" });
|
||||||
@@ -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 };
|
||||||
@@ -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" });
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -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(),
|
name: name.trim(),
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
service_group_id: groupId,
|
service_group_id: groupId,
|
||||||
...configPayload,
|
ips,
|
||||||
|
domains,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export class ApiError extends Error {
|
|||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = localStorage.getItem('cfdm_token')
|
const token = localStorage.getItem('cfdm_token')
|
||||||
const headers = new Headers(init?.headers)
|
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}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
const res = await fetch(path, { ...init, headers })
|
const res = await fetch(path, { ...init, headers })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export interface ParsedFqdn {
|
export interface ParsedFqdn {
|
||||||
zoneName: string
|
zoneName: string
|
||||||
hostname: string
|
hostname: string
|
||||||
fqdn: string
|
fqdn: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const subdomainSchema = z.object({
|
export const subdomainSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
|||||||
+18
-18
@@ -1,4 +1,4 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const groupSchema = z.object({
|
export const groupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -158,25 +158,25 @@ export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
|||||||
export type Certificate = z.infer<typeof certificateSchema>
|
export type Certificate = z.infer<typeof certificateSchema>
|
||||||
|
|
||||||
export const createGroupSchema = z.object({
|
export const createGroupSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
slug: z.string().min(1, 'Укажите slug'),
|
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ipv4Schema = z
|
const ipv4Schema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
|
/^(?:(?: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({
|
const serviceDomainInputSchema = z.object({
|
||||||
fqdn: z.string().min(1, 'Укажите FQDN'),
|
fqdn: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ FQDN'),
|
||||||
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'),
|
target_ips: z.array(ipv4Schema).min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Е╨╛╤В╤П ╨▒╤Л ╨╛╨┤╨╕╨╜ IP'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createServiceSchema = z.object({
|
export const createServiceSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
slug: z.string().min(1, 'Укажите slug'),
|
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createServiceWithConfigSchema = createServiceSchema.extend({
|
export const createServiceWithConfigSchema = createServiceSchema.extend({
|
||||||
@@ -186,8 +186,8 @@ export const createServiceWithConfigSchema = createServiceSchema.extend({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const createServiceBindingSchema = z.object({
|
export const createServiceBindingSchema = z.object({
|
||||||
domain_id: z.string().min(1, 'Выберите домен'),
|
domain_id: z.string().min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╨┤╨╛╨╝╨╡╨╜'),
|
||||||
service_id: z.string().min(1, 'Выберите сервис'),
|
service_id: z.string().min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Б╨╡╤А╨▓╨╕╤Б'),
|
||||||
hostname: z.string().optional(),
|
hostname: z.string().optional(),
|
||||||
target_ip: z.string().optional(),
|
target_ip: z.string().optional(),
|
||||||
})
|
})
|
||||||
@@ -198,19 +198,19 @@ export const updateDomainGroupSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const createDomainSchema = z.object({
|
export const createDomainSchema = z.object({
|
||||||
zone_name: z.string().min(1, 'Укажите имя зоны'),
|
zone_name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П ╨╖╨╛╨╜╤Л'),
|
||||||
group_id: z.string(),
|
group_id: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
username: z.string().min(1, 'Укажите имя пользователя'),
|
username: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П ╨┐╨╛╨╗╤М╨╖╨╛╨▓╨░╤В╨╡╨╗╤П'),
|
||||||
password: z.string().min(1, 'Укажите пароль'),
|
password: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨┐╨░╤А╨╛╨╗╤М'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createDnsRecordSchema = z.object({
|
export const createDnsRecordSchema = z.object({
|
||||||
record_type: z.enum(['A', 'AAAA', 'CNAME', 'TXT', 'MX']),
|
record_type: z.enum(['A', 'AAAA', 'CNAME', 'TXT', 'MX']),
|
||||||
name: z.string().min(1, 'Укажите имя'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╕╨╝╤П'),
|
||||||
content: z.string().min(1, 'Укажите значение'),
|
content: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╖╨╜╨░╤З╨╡╨╜╨╕╨╡'),
|
||||||
ttl: z.number().int().min(1),
|
ttl: z.number().int().min(1),
|
||||||
proxied: z.boolean(),
|
proxied: z.boolean(),
|
||||||
})
|
})
|
||||||
@@ -220,8 +220,8 @@ export type CreateServiceInput = z.infer<typeof createServiceSchema>
|
|||||||
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
|
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
|
||||||
|
|
||||||
export const updateServiceConfigSchema = z.object({
|
export const updateServiceConfigSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название').optional(),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡').optional(),
|
||||||
slug: z.string().min(1, 'Укажите slug').optional(),
|
slug: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ slug').optional(),
|
||||||
service_group_id: z.number().nullable().optional(),
|
service_group_id: z.number().nullable().optional(),
|
||||||
ips: z.array(ipv4Schema).optional(),
|
ips: z.array(ipv4Schema).optional(),
|
||||||
domains: z
|
domains: z
|
||||||
@@ -232,7 +232,7 @@ export const updateServiceConfigSchema = z.object({
|
|||||||
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
|
export type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>
|
||||||
|
|
||||||
export const createServiceGroupSchema = z.object({
|
export const createServiceGroupSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
type: serviceGroupTypeSchema.default('custom'),
|
type: serviceGroupTypeSchema.default('custom'),
|
||||||
icon: z.string().nullable().optional(),
|
icon: z.string().nullable().optional(),
|
||||||
domain: z.string().nullable().optional(),
|
domain: z.string().nullable().optional(),
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-3302
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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?))
|
|
||||||
}
|
|
||||||
@@ -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?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -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 })))
|
|
||||||
}
|
|
||||||
@@ -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 })))
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -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?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -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?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -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 })))
|
|
||||||
}
|
|
||||||
@@ -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?))
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod router;
|
|
||||||
pub mod handlers;
|
|
||||||
|
|
||||||
pub use router::create_router;
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod client;
|
|
||||||
pub mod retry;
|
|
||||||
pub mod types;
|
|
||||||
|
|
||||||
pub use client::CloudflareClient;
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
pub mod entities;
|
|
||||||
pub mod subdomain;
|
|
||||||
pub mod validators;
|
|
||||||
|
|
||||||
pub use entities::*;
|
|
||||||
pub use subdomain::*;
|
|
||||||
pub use validators::*;
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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?)
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user