Refactor Docker setup to integrate Web UI and streamline configuration
Publish telemt-api gateway Docker image / test (push) Successful in 23s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m54s

- Updated Dockerfile to build and embed the SvelteKit Web UI directly into the gateway image, eliminating the need for a separate web service.
- Modified .dockerignore to exclude unnecessary directories related to the web service.
- Adjusted config.compose.yaml to remove CORS settings for the web service, as the UI now shares the same origin as the API.
- Enhanced README.md to reflect the new single-port architecture for accessing both the Web UI and API.
- Removed the standalone web Dockerfile and updated related documentation for local development and build processes.
This commit is contained in:
Denozordec
2026-03-30 10:42:28 +07:00
parent 6421a9b28d
commit d69849e5e2
15 changed files with 182 additions and 75 deletions
+3
View File
@@ -7,3 +7,6 @@ docs
.gitea
docker-compose*.yml
deploy
web/node_modules
web/.svelte-kit
web/build
+16 -5
View File
@@ -1,24 +1,35 @@
# syntax=docker/dockerfile:1
# --- Web UI (SvelteKit static) — тот же origin, что и API (порт шлюза)
FROM node:22-bookworm AS webui
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
# Пустой URL: fetch идёт на тот же хост/порт, что и панель
ENV PUBLIC_TELEMT_GATEWAY_URL=
RUN npm run build
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod ./
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=webui /web/build/ ./internal/webui/static/
RUN go mod tidy && go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway
# Alpine: non-root + wget for HEALTHCHECK (distroless has no shell/wget).
FROM alpine:3.19
RUN apk add --no-cache ca-certificates wget \
&& addgroup -S gateway -g 65532 \
&& adduser -S -u 65532 -G gateway gateway \
&& mkdir -p /var/lib/telemt-gateway \
&& chown gateway:gateway /var/lib/telemt-gateway
&& addgroup -S gateway -g 65532 \
&& adduser -S -u 65532 -G gateway gateway \
&& mkdir -p /var/lib/telemt-gateway \
&& chown gateway:gateway /var/lib/telemt-gateway
COPY --from=build /out/gateway /gateway
USER gateway:gateway
EXPOSE 8080
ENV CONFIG_PATH=/etc/telemt-gateway/config.yaml
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O- http://127.0.0.1:8080/health >/dev/null || exit 1
CMD wget -q -O- http://127.0.0.1:8080/health >/dev/null || exit 1
ENTRYPOINT ["/gateway"]
+9 -9
View File
@@ -1,12 +1,12 @@
# telemt-api
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…``{base_url}/v1/…`, агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), метрики Prometheus на `/metrics`.
HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…``{base_url}/v1/…`, агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`.
## Быстрый старт (Linux)
Предполагается установлены Docker и Docker Compose v2.
**Рекомендуется** брать уже собранный образ из Container Registry Gitea (после каждого push в репозиторий CI обновляет теги, в том числе `latest`):
**Рекомендуется** брать уже собранный образ из Container Registry Gitea (после каждого push CI обновляет теги, в том числе `latest`). В актуальном образе вместе с шлюзом уже **встроена панель** на `http://<хост>:8080/`:
```bash
# при необходимости (закрытый registry): логин Gitea + PAT с read:package
@@ -30,25 +30,25 @@ docker run -d --name telemt-gateway \
curl -sS http://127.0.0.1:8080/health
curl -sS http://127.0.0.1:8080/api/main_srv/health
# панель в браузере: http://127.0.0.1:8080/
```
Обновление образа: `docker pull git.shts.su/denozord/telemt-api:latest` и пересоздайте контейнер (`docker rm -f telemt-gateway` и снова `docker run …`).
### Compose
Тот же образ шлюза подтягивается из registry (без локальной сборки). Сервис **web** (панель в браузере) собирается из каталога `web/` при первом запуске:
Один сервис **gateway** в образе уже есть и API, и статика панели (см. [Dockerfile](Dockerfile)).
```bash
git clone <url-репозитория> && cd telemt-api
docker compose pull
docker compose up -d --build
docker compose up -d
docker compose logs -f gateway
```
- Шлюз: `http://127.0.0.1:8080` (проверка: `curl -sS http://127.0.0.1:8080/health`).
- Web UI: `http://127.0.0.1:4173` — в [config.compose.yaml](config.compose.yaml) заданы `cors_allowed_origins` под этот origin; `PUBLIC_TELEMT_GATEWAY_URL` при сборке образа UI указывает на шлюз на хосте (`http://127.0.0.1:8080`).
- Всё на **одном порту**: `http://127.0.0.1:8080/` — Web UI, `http://127.0.0.1:8080/api/…` — шлюз, `http://127.0.0.1:8080/health` — проверка живости.
Подробнее по панели: [web/README.md](web/README.md).
Подробнее по фронту и dev-режиму: [web/README.md](web/README.md). Полный сценарий Docker (CLI, compose, сборка, CI): [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md).
### Web UI (локально без Docker)
@@ -71,7 +71,7 @@ cors_allowed_origins:
### Локальная сборка образа
Если нужен образ из исходников на этой машине: `docker build -t telemt-api-gateway:local .` и в `docker run` укажите тег `telemt-api-gateway:local`. Подробнее — [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md).
`docker build -t telemt-api-gateway:local .` в образ попадают Web UI и бинарь шлюза (см. [Dockerfile](Dockerfile)). В `docker run` укажите этот тег. Подробнее — [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md).
## Документация
@@ -81,7 +81,7 @@ cors_allowed_origins:
| **[docs/API.md](docs/API.md)** | Контракт Telemt Control API (`/v1/…`) |
| **[docs/AGGREGATE.md](docs/AGGREGATE.md)** | Агрегирующие эндпоинты шлюза (`/api/agg/…`), CORS, кэш |
| **[docs/AGGREGATE_OPENAPI.yaml](docs/AGGREGATE_OPENAPI.yaml)** | OpenAPI 3 черновик для `/api/agg/*` (генерация типов для UI) |
| **[web/README.md](web/README.md)** | Web UI (SvelteKit + shadcn-svelte): установка, `PUBLIC_TELEMT_GATEWAY_URL`, Docker |
| **[web/README.md](web/README.md)** | Web UI (SvelteKit): разработка с Vite, `PUBLIC_TELEMT_GATEWAY_URL`, встраивание в образ шлюза |
| **[docs/GEOIP.md](docs/GEOIP.md)** | GeoLite2 City (страна/город) и опционально ASN (номер AS, организация) для IP в `unique-ips` |
## Сборка и тесты без Docker
+1 -4
View File
@@ -4,10 +4,7 @@ listen: ":8080"
allow_all: true
whitelist_cidrs: []
trusted_proxies: []
# UI в Docker (сервис web): браузер на другом origin — нужен CORS
cors_allowed_origins:
- "http://127.0.0.1:4173"
- "http://localhost:4173"
# UI отдаётся с того же порта, что и API (образ шлюза) — отдельный CORS для панели не нужен.
servers:
- alias: main_srv
base_url: http://host.docker.internal:9091
+5 -1
View File
@@ -2,6 +2,9 @@
#
# Semantics: client calls GET /api/{alias}/health
# forwarded to GET {base_url}/v1/health
#
# Web UI (SvelteKit) в образе Docker встроен в тот же процесс: GET / — панель,
# /api/… — API. CORS для панели на том же origin не нужен.
listen: ":8080"
@@ -23,7 +26,8 @@ whitelist_cidrs:
# - "10.0.0.0/8"
trusted_proxies: []
# SPA на другом origin (preflight OPTIONS + Access-Control-Allow-Origin):
# CORS только если фронт на другом origin (например Vite :5173 при разработке).
# В production за nginx на одном хосте с шлюзом обычно не требуется.
# cors_allowed_origins:
# - "http://localhost:5173"
# # - "*"
+1 -10
View File
@@ -1,16 +1,7 @@
services:
web:
build:
context: ./web
dockerfile: Dockerfile
args:
# URL шлюза в браузере (встраивается при сборке)
PUBLIC_TELEMT_GATEWAY_URL: http://127.0.0.1:8080
ports:
- "4173:4173"
gateway:
image: git.shts.su/denozord/telemt-api:latest
# Локальная сборка вместо pull: укажите build: . и image: telemt-api-gateway:local
# Локальная сборка с Web UI внутри образа: build: . и image: telemt-api-gateway:local
ports:
- "8080:8080"
volumes:
+12 -6
View File
@@ -18,6 +18,7 @@
Шлюз — это один HTTP‑вход для нескольких экземпляров [Telemt Control API](API.md):
- **Web UI** (в образе Docker): статика панели на **`GET /`** (и клиентские маршруты SPA), агрегаты и прокси на **`/api/…`**. Тот же порт, что и у API (например `8080`). Исходники UI — каталог [web/](../web/README.md), сборка встроена в [Dockerfile](../Dockerfile) (стадия Node + `embed` в Go).
- **Белый список IP** (CIDR): кто может обращаться к шлюзу (кроме `GET /health`, см. ниже).
- **Маршрутизация по alias**: клиент вызывает `GET /api/{alias}/health`, шлюз проксирует на `{base_url}/v1/health` у соответствующего сервера.
- **Метрики Prometheus**: `GET /metrics` (под тем же правилом whitelist, что и API).
@@ -28,7 +29,8 @@
## Требования
- Установленные [Docker](https://docs.docker.com/get-docker/) и при необходимости [Docker Compose](https://docs.docker.com/compose/) v2.
- Для локальной сборки из исходников: [Go 1.22+](https://go.dev/dl/) (опционально, если не используете только готовый образ из registry).
- Для локальной сборки **Docker-образа** из репозитория: Docker сам подтянет [Node](https://nodejs.org/) на стадии сборки фронта и [Go 1.22+](https://go.dev/dl/) на стадии компиляции (см. [Dockerfile](../Dockerfile)).
- Для `go test ./...` без Docker на машине нужен только Go 1.22+.
## Минимальная конфигурация
@@ -106,7 +108,7 @@ docker login git.shts.su
docker build -t telemt-api-gateway:local .
```
В командах `docker run` ниже вместо имени из registry подставьте `telemt-api-gateway:local`.
Сборка **многостадийная**: сначала `npm ci` + `npm run build` в каталоге `web/` (в бандл вшивается пустой `PUBLIC_TELEMT_GATEWAY_URL`, запросы API с того же origin), затем компиляция Go со встраиванием `web/build` через `embed`. В командах `docker run` ниже вместо имени из registry подставьте `telemt-api-gateway:local`.
## Запуск через Docker CLI
@@ -125,9 +127,12 @@ docker run -d --name telemt-gateway \
```bash
curl -sS -i http://127.0.0.1:8080/health
curl -sS -i http://127.0.0.1:8080/api/main_srv/health
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
```
Второй запрос проксируется на `{base_url}/v1/health` для alias `main_srv`.
Второй запрос проксируется на `{base_url}/v1/health` для alias `main_srv`. Третий — HTML панели (в образах, собранных с Web UI; ожидайте `200`).
Панель в браузере: `http://127.0.0.1:8080/` (при `allow_all: false` ваш IP должен быть в `whitelist_cidrs`, иначе для `/` будет `403`, как и для API).
Остановка и удаление:
@@ -138,7 +143,7 @@ docker rm telemt-gateway
## Запуск через Docker Compose
В репозитории есть [docker-compose.yml](../docker-compose.yml) и пример [config.compose.yaml](../config.compose.yaml) с `allow_all: true` и `base_url: http://host.docker.internal:9091` (Telemt на хосте; на Docker Desktop для Linux это обычно работает из коробки). Образ по умолчанию **скачивается** из registry, локальная сборка не требуется.
В репозитории есть [docker-compose.yml](../docker-compose.yml) (один сервис **gateway**) и пример [config.compose.yaml](../config.compose.yaml) с `allow_all: true` и `base_url: http://host.docker.internal:9091` (Telemt на хосте; на Docker Desktop для Linux это обычно работает из коробки). Образ по умолчанию **скачивается** из registry, локальная сборка не требуется. UI доступен на том же порту, что и шлюз: `http://127.0.0.1:8080/`.
```bash
docker compose pull
@@ -159,6 +164,7 @@ docker compose down
| Сценарий | Ожидание |
|----------|----------|
| `GET /health` | `200`, JSON `{"status":"ok"}` |
| `GET /` (образ с Web UI) | `200`, HTML панели |
| Разрешённый IP, корректный alias | ответ бэкенда (например `200` для `/v1/health`) |
| IP не в whitelist | `403`, JSON с `code: forbidden` |
| Неизвестный alias | `404`, JSON с `code: not_found` |
@@ -167,9 +173,9 @@ docker compose down
## Обновление и CI/CD
- **Образ**: подтяните свежий тег (`docker pull git.shts.su/denozord/telemt-api:latest` или `docker compose pull`), пересоздайте контейнер (`docker compose up -d` или новый `docker run` с тем же volume конфига). Локальная пересборка нужна только если вы меняете Dockerfile/код и не пользуетесь CI.
- **Образ**: подтяните свежий тег (`docker pull git.shts.su/denozord/telemt-api:latest` или `docker compose pull`), пересоздайте контейнер (`docker compose up -d` или новый `docker run` с тем же volume конфига). Локальная пересборка нужна только если вы меняете Dockerfile/код и не пользуетесь CI. Образы, собранные **до** добавления стадии `web/` в Dockerfile, могут отдавать на `/` только заглушку — нужен образ из актуального CI или локальный `docker build`.
- **Конфиг**: отредактируйте файл на хосте и перезапустите контейнер (шлюз не перечитывает конфиг на лету).
- **Gitea Actions**: workflow [.gitea/workflows/docker.yaml](../.gitea/workflows/docker.yaml) сначала выполняет `go mod tidy && go test ./...`, затем собирает образ через Buildx и пушит в Container Registry Gitea.
- **Gitea Actions**: workflow [.gitea/workflows/docker.yaml](../.gitea/workflows/docker.yaml) сначала выполняет `go mod tidy && go test ./...`, затем собирает образ через Buildx по [Dockerfile](../Dockerfile) (стадии Node для `web/` и Go) и пушит в Container Registry Gitea.
- В репозитории должен быть secret **`ACTIONS_PAT`** — personal access token пользователя с правом **`write:package`** (и при необходимости `read:package`), как для обычного `docker login` к registry.
- Логин в registry: пользователь **`gitea.actor`** (кто запустил workflow), пароль — этот PAT.
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/telemt/telemt-api/internal/config"
"github.com/telemt/telemt-api/internal/geoip"
"github.com/telemt/telemt-api/internal/proxy"
"github.com/telemt/telemt-api/internal/webui"
)
// Gateway serves health, metrics, and proxied API routes.
@@ -29,6 +30,7 @@ type Gateway struct {
transport *http.Transport
promHandler http.Handler
corsAllowed []string
webUI http.Handler
}
// NewGateway builds handlers and reverse proxies from parsed config.
@@ -75,6 +77,7 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa
}
g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...)
g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL)
g.webUI = webui.Handler()
return g, nil
}
@@ -231,7 +234,7 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) {
return
}
if !strings.HasPrefix(r.URL.Path, prefix) {
http.NotFound(w, r)
g.webUI.ServeHTTP(w, r)
return
}
trim := strings.TrimPrefix(r.URL.Path, prefix)
+6
View File
@@ -0,0 +1,6 @@
package webui
import "embed"
//go:embed all:static
var static embed.FS
+68
View File
@@ -0,0 +1,68 @@
package webui
import (
"io/fs"
"mime"
"net/http"
"path"
"strconv"
"strings"
)
// Handler отдаёт статику SvelteKit (embed) и index.html для клиентских маршрутов SPA.
func Handler() http.Handler {
root, err := fs.Sub(static, "static")
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "." || name == "" {
name = "index.html"
}
b, err := fs.ReadFile(root, name)
if err != nil {
if path.Ext(name) != "" {
http.NotFound(w, r)
return
}
b, err = fs.ReadFile(root, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
name = "index.html"
}
ct := mime.TypeByExtension(path.Ext(name))
if ct == "" {
ct = "application/octet-stream"
}
if strings.HasPrefix(ct, "text/") && !strings.Contains(ct, "charset") {
ct = ct + "; charset=utf-8"
}
w.Header().Set("Content-Type", ct)
if name == "index.html" {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
if r.Method == http.MethodHead {
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
})
}
+28
View File
@@ -0,0 +1,28 @@
package webui
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler_index(t *testing.T) {
h := Handler()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("GET /: %d", rr.Code)
}
if got := rr.Header().Get("Content-Type"); got == "" {
t.Fatal("missing Content-Type")
}
}
func TestHandler_spaFallback(t *testing.T) {
h := Handler()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/servers/foo", nil))
if rr.Code != http.StatusOK {
t.Fatalf("GET /servers/foo: %d", rr.Code)
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>Telemt Panel</title>
</head>
<body>
<p>
Заглушка: UI не собран. Соберите фронт (<code>cd web &amp;&amp; npm run build</code>) перед сборкой образа шлюза, либо
используйте Dockerfile в корне репозитория.
</p>
</body>
</html>
+3 -2
View File
@@ -1,2 +1,3 @@
# Базовый URL шлюза telemt-api (без завершающего слэша)
PUBLIC_TELEMT_GATEWAY_URL=http://127.0.0.1:8080
# Пусто = запросы к API на том же origin, что и панель (образ Docker со встроенным UI).
# Для dev (Vite :5173, шлюз :8080): http://127.0.0.1:8080
PUBLIC_TELEMT_GATEWAY_URL=
-16
View File
@@ -1,16 +0,0 @@
# Статическая сборка SvelteKit и раздача через serve
FROM node:22-alpine AS build
WORKDIR /app
ARG PUBLIC_TELEMT_GATEWAY_URL=http://127.0.0.1:8080
ENV PUBLIC_TELEMT_GATEWAY_URL=$PUBLIC_TELEMT_GATEWAY_URL
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
RUN npm install -g serve@14
WORKDIR /srv
COPY --from=build /app/build ./build
EXPOSE 4173
CMD ["serve", "-s", "build", "-l", "4173"]
+13 -21
View File
@@ -1,16 +1,17 @@
# Telemt Panel (Web UI)
SvelteKit + shadcn-svelte: обзор флота (`/api/agg/*`), пользователи и IP, панель по каждой ноде через прокси шлюза (`/api/{alias}/…`).
SvelteKit + shadcn-svelte. В **production** статика собирается и **встраивается в образ шлюза** ([Dockerfile](../Dockerfile) в корне репозитория): панель и API на **одном порту** (например `http://127.0.0.1:8080/` — UI, `/api/…` — шлюз).
## Переменные окружения
## Переменная `PUBLIC_TELEMT_GATEWAY_URL`
| Переменная | Описание |
| Значение | Когда |
| --- | --- |
| `PUBLIC_TELEMT_GATEWAY_URL` | Базовый URL шлюза **telemt-api** без завершающего `/` (например `http://127.0.0.1:8080`). Встраивается в клиент при сборке. |
| *(пусто)* | Тот же хост и порт, что у страницы (Docker-образ шлюза, `npm run build` в Dockerfile с `ENV PUBLIC_TELEMT_GATEWAY_URL=`) |
| `http://127.0.0.1:8080` | Локальная разработка: Vite на `:5173`, шлюз на `:8080` |
Скопируйте [.env.example](.env.example) в `.env` и при необходимости измените URL.
Скопируйте [.env.example](.env.example) в `.env` и при необходимости задайте URL.
## Разработка
## Разработка (Vite отдельно от Go)
```powershell
cd web
@@ -18,39 +19,30 @@ npm install
npm run dev
```
Откройте в браузере адрес Vite (по умолчанию `http://localhost:5173`). В конфиге шлюза укажите CORS, например:
Откройте `http://localhost:5173`. В конфиге шлюза включите [CORS](../docs/AGGREGATE.md) для этого origin:
```yaml
cors_allowed_origins:
- "http://localhost:5173"
```
## Сборка
## Сборка только фронта
```powershell
npm run build
npm run preview # проверка статики
```
## Типы из OpenAPI агрегатов
Артефакт — каталог `build/`, при сборке **Docker-образа шлюза** он копируется в `internal/webui/static/` и попадает в бинарник через `embed`.
После изменения [../docs/AGGREGATE_OPENAPI.yaml](../docs/AGGREGATE_OPENAPI.yaml):
## Типы из OpenAPI агрегатов
```powershell
npm run gen:api
```
## Docker
Сборка образа из каталога `web/`:
```powershell
docker build -t telemt-web:local --build-arg PUBLIC_TELEMT_GATEWAY_URL=http://127.0.0.1:8080 .
```
В [docker-compose.yml](../docker-compose.yml) сервис `web` отдаёт UI на порту **4173**. URL шлюза в образе задаётся **build-arg** (см. compose). Браузер на хосте обращается к шлюзу по `http://127.0.0.1:8080` — при другом адресе пересоберите образ с нужным `PUBLIC_TELEMT_GATEWAY_URL`.
Источник: [../docs/AGGREGATE_OPENAPI.yaml](../docs/AGGREGATE_OPENAPI.yaml).
## Ограничения
- Секреты upstream к Telemt задаются на шлюзе (`authorization_env`), не в браузере.
- Mutating CORS: шлюз должен разрешать `POST`, `PATCH`, `DELETE` в preflight (в текущей версии репозитория заголовок `Access-Control-Allow-Methods` это учитывает).
- Для мутаций из другого origin шлюз отдаёт нужные заголовки CORS (в т.ч. методы `POST`, `PATCH`, `DELETE`).