Compare commits

..
3 Commits
Author SHA1 Message Date
Denozordec 68f9d4b832 refactor(firewall): simplify SQL queries for firewall client retrieval
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m57s
Refactored the SQL queries in the Postgres repository for listing and retrieving firewall clients by introducing a constant for the selected columns. This change improves code readability and maintainability by reducing duplication in the query definitions. No functional changes were made to the data retrieval process.
2026-07-08 21:16:57 +07:00
Denozordec 72045afcde feat(firewall): improve error handling and documentation for firewall enrollment
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 43s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m41s
Enhanced the firewall enrollment process by implementing better error handling for HTTP responses, specifically addressing database schema issues. Updated the documentation to include migration requirements for PostgreSQL and clarified the steps to take if enrollment fails due to an outdated schema. This ensures users are better informed about necessary actions during deployment.
2026-07-08 21:00:27 +07:00
Denozordec e15768b25b feat(firewall): implement public HTTPS endpoints for firewall scripts and enhance URL handling
CI / changes (push) Successful in 15s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m39s
Added public HTTPS endpoints for firewall installation and enrollment scripts, allowing access without API keys. Updated the URL handling in the firewall code to ensure all suggested control plane URLs are served over HTTPS. Enhanced documentation to reflect the new public endpoints and their usage. Updated tests to verify the correct behavior of the new URL handling logic.
2026-07-08 18:54:27 +07:00
14 changed files with 158 additions and 34 deletions
+12 -2
View File
@@ -34,6 +34,16 @@ import {
} from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
function httpsOrigin(origin: string): string {
try {
const u = new URL(origin)
u.protocol = 'https:'
return u.origin
} catch {
return origin.replace(/^http:/i, 'https:')
}
}
export const Route = createFileRoute('/_auth/firewall')({
component: FirewallPage,
})
@@ -51,13 +61,13 @@ function FirewallPage() {
const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
useEffect(() => {
if (installCtx?.suggested_cp_url) {
setCpUrl(installCtx.suggested_cp_url)
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
}
if (installCtx?.bundle_seed) {
setSeed(installCtx.bundle_seed)
@@ -159,10 +159,18 @@ services:
condition: service_started
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+8
View File
@@ -247,10 +247,18 @@ services:
- evobgp-all
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+8
View File
@@ -162,10 +162,18 @@ services:
condition: service_started
labels:
- traefik.enable=true
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
- traefik.http.routers.evobgp-firewall-public.tls=true
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-firewall-public.priority=100
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
- traefik.http.routers.evobgp-web.entrypoints=websecure
- traefik.http.routers.evobgp-web.tls=true
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
- traefik.http.routers.evobgp-web.priority=10
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
+1 -1
View File
@@ -17,7 +17,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
location = /metrics {
+4
View File
@@ -21,6 +21,10 @@
## Установка на сервер
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
```bash
curl -fsSL https://<api>/v1/firewall/install.sh | \
EVOBGP_CP_URL=https://<api> \
+1
View File
@@ -153,6 +153,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u
- `http://<WEBUI_DOMAIN>` должен редиректить на `https://<WEBUI_DOMAIN>`;
- с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS;
- с неразрешенного IP Traefik вернет `403`.
- исключение: `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll` — публичные, без whitelist (см. [firewall.md](firewall.md)).
Health API: `http://<IP>:8080/v1/health`.
+9 -1
View File
@@ -38,10 +38,18 @@ CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
+22
View File
@@ -16,6 +16,8 @@ import (
"evobgp/internal/pipeline"
"evobgp/internal/runtimelogs"
"evobgp/internal/store"
"github.com/jackc/pgx/v5/pgconn"
)
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
@@ -188,9 +190,29 @@ func writeStoreErr(w http.ResponseWriter, err error) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
if writePostgresStoreErr(w, err) {
return
}
writeInternalError(w, "store", err)
}
func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
switch pgErr.Code {
case "42P01":
writeProblem(w, http.StatusServiceUnavailable, "Service Unavailable",
"database schema outdated; restart API after deploy or apply migration 000027_firewall")
return true
case "23505":
writeProblem(w, http.StatusConflict, "Conflict", "resource already exists")
return true
}
return false
}
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
+10 -13
View File
@@ -50,20 +50,13 @@ func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]any{
"bundle_seed": seed,
"bundle_seed_configured": seed != "",
"suggested_cp_url": requestBaseURL(r),
"install_sh_url": requestBaseURL(r) + "/v1/firewall/install.sh",
"suggested_cp_url": publicHTTPSBaseURL(r),
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
})
}
func requestBaseURL(r *http.Request) string {
scheme := "https"
if r.TLS == nil {
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); xf != "" {
scheme = strings.ToLower(strings.Split(xf, ",")[0])
} else if strings.EqualFold(r.URL.Scheme, "http") {
scheme = "http"
}
}
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
func publicHTTPSBaseURL(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
host = strings.TrimSpace(strings.Split(xf, ",")[0])
@@ -71,7 +64,11 @@ func requestBaseURL(r *http.Request) string {
if host == "" {
return ""
}
return scheme + "://" + host
return "https://" + host
}
func requestBaseURL(r *http.Request) string {
return publicHTTPSBaseURL(r)
}
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
@@ -126,7 +123,7 @@ func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Reque
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
return
}
writeInternalError(w, "internal", err)
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
+4 -1
View File
@@ -186,7 +186,10 @@ func TestFirewallInstallContext(t *testing.T) {
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
t.Fatal("bundle_seed_configured want true")
}
if url, _ := ctx["install_sh_url"].(string); !strings.HasSuffix(url, "/v1/firewall/install.sh") {
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
t.Fatalf("suggested_cp_url=%q want https", url)
}
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
t.Fatalf("install_sh_url=%q", url)
}
+10 -15
View File
@@ -13,14 +13,17 @@ import (
"github.com/jackc/pgx/v5"
)
const firewallClientSelectCols = `
id, name, COALESCE(hostname, ''), token_prefix, status,
last_seen_at, COALESCE(last_seen_at_source, ''), COALESCE(last_seen_ip, ''),
last_apply_at, COALESCE(last_apply_status, ''), COALESCE(last_apply_error, ''),
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0), COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
@@ -40,11 +43,7 @@ func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient
func (p *Postgres) GetFirewallClient(tenantID, id string) (*store.FirewallClient, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT `+firewallClientSelectCols+`
FROM firewall_client WHERE id=$1 AND tenant_id=$2`, id, tenantID)
c, err := scanFirewallClientRow(row.Scan, tenantID)
if err != nil {
@@ -147,11 +146,7 @@ func (p *Postgres) LookupFirewallClientByTokenHash(hash []byte) (*store.Firewall
}
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT tenant_id, id, name, hostname, token_prefix, status,
last_seen_at, last_seen_at_source, last_seen_ip,
last_apply_at, last_apply_status, last_apply_error,
last_apply_prefix_count, last_apply_ip_count, last_apply_source,
client_version, created_at, approved_at, approved_by_api_key_id, revoked_at
SELECT tenant_id, `+firewallClientSelectCols+`
FROM firewall_client WHERE token_hash=$1`, hash)
c, err := scanFirewallClientLookupRow(row.Scan)
if err != nil {
@@ -0,0 +1,52 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/db"
"evobgp/internal/store"
)
func TestPostgresFirewallClientCreateAndGetIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, true)
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := pg.DemoIDs()
if tenant == "" {
t.Fatal("demo tenant required")
}
tok := "evobgp_fw_pgtest_" + t.Name()
hash := authkey.HashToken(tok)
client, err := pg.CreateFirewallClient(tenant, &store.FirewallClientCreate{
Name: "pg-firewall-test",
Hostname: "test.local",
TokenPrefix: tok[:12],
TokenHash: hash,
ClientVersion: "test/1",
})
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := pg.GetFirewallClient(tenant, client.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != "pg-firewall-test" || got.Status != "pending" {
t.Fatalf("got %+v", got)
}
_ = pg.DeleteFirewallClient(tenant, client.ID)
}
+9 -1
View File
@@ -38,10 +38,18 @@ CP_URL="${EVOBGP_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","client_token":"%s","client_version":"install.sh/1"}' \
"$EVOBGP_CLIENT_NAME" "$HOSTNAME" "$CLIENT_TOKEN")
RESP=$(curl -fsS -X POST "${CP_URL}/v1/firewall/enroll" \
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/firewall/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoBGP-Seed: ${EVOBGP_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" ]]; then
echo "evobgp-firewall enroll failed: HTTP ${ENROLL_CODE} from ${CP_URL}/v1/firewall/enroll" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then