Add CORS support and Docker configuration for web service
Publish telemt-api gateway Docker image / test (push) Successful in 24s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 1m18s

- Added CORS allowed origins in config.compose.yaml to enable cross-origin requests from the web service.
- Updated docker-compose.yml to include a new web service with build context and port mapping for local development.
- Enhanced gateway.go to support additional HTTP methods in CORS responses.
- Updated README.md to document the new web service and its configuration requirements.
This commit is contained in:
Denozordec
2026-03-30 10:31:10 +07:00
parent 04c257a84e
commit ef98560e9f
167 changed files with 8645 additions and 1 deletions
+401
View File
@@ -0,0 +1,401 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/agg/summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Сводка флота и топы пользователей */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
top_n?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeSummary"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agg/traffic": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Трафик по пользователям и серверам */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeTrafficRows"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agg/unique-ips": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Уникальные IP с привязкой к серверам (и опционально GeoIP) */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
/** @description false — не обогащать GeoIP */
geo?: "false" | "true";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeUniqueIPs"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agg/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Список пользователей с merge по серверам */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
include_links?: "true" | "false";
min_total_megabytes?: number;
min_total_octets?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeUsersRows"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agg/user/{username}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Один пользователь (тот же объект, что в users[]) */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
include_links?: "true" | "false";
};
header?: never;
path: {
username: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeUsersRow"];
};
};
/** @description Пользователь не найден ни на одном upstream */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agg/fleet-status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health + system/info по всем выбранным серверам */
get: {
parameters: {
query?: {
/** @description Список алиасов через запятую */
aliases?: components["parameters"]["aliases"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AggEnvelopeFleetStatus"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AggSuccessBase: {
/** @enum {boolean} */
ok: true;
/** Format: date-time */
generated_at: string;
/** @description true если часть upstream недоступна */
partial?: boolean;
};
AggEnvelopeSummary: components["schemas"]["AggSuccessBase"] & {
/** @description SummaryData (см. реализацию / доку AGGREGATE.md) */
data?: Record<string, never>;
};
AggEnvelopeTrafficRows: components["schemas"]["AggSuccessBase"] & {
data?: components["schemas"]["TrafficRow"][];
};
AggEnvelopeUniqueIPs: components["schemas"]["AggSuccessBase"] & {
data?: components["schemas"]["UniqueIPsRow"][];
};
AggEnvelopeUsersRows: components["schemas"]["AggSuccessBase"] & {
data?: components["schemas"]["UsersRow"][];
};
AggEnvelopeUsersRow: components["schemas"]["AggSuccessBase"] & {
data?: components["schemas"]["UsersRow"];
};
AggEnvelopeFleetStatus: components["schemas"]["AggSuccessBase"] & {
data?: components["schemas"]["FleetStatusData"];
};
TrafficRow: {
username?: string;
servers?: {
[key: string]: components["schemas"]["TrafficServerStats"];
};
};
TrafficServerStats: {
/** Format: float */
total_megabytes?: number;
/** Format: int64 */
current_connections?: number;
revision?: string;
};
UniqueIPsRow: {
username?: string;
ips?: components["schemas"]["IPAssignments"][];
};
IPAssignments: {
ip?: string;
active_on_servers?: string[];
recent_on_servers?: string[];
primary_server?: string | null;
country_code?: string | null;
country_name?: string | null;
city_name?: string | null;
/** Format: int64 */
asn?: number | null;
as_organization?: string | null;
};
UserLinks: {
classic?: string[];
secure?: string[];
tls?: string[];
};
UsersRow: {
username?: string;
/** Format: float */
total_megabytes?: number;
by_server?: {
[key: string]: components["schemas"]["TrafficServerStats"];
};
links?: components["schemas"]["UserLinks"];
/** Format: int64 */
active_unique_ips?: number;
/** Format: int64 */
recent_unique_ips?: number;
user_ad_tag?: string | null;
/** Format: int64 */
max_tcp_conns?: number | null;
expiration_rfc3339?: string | null;
/** Format: int64 */
data_quota_bytes?: number | null;
/** Format: int64 */
max_unique_ips?: number | null;
};
HealthData: {
status?: string;
read_only?: boolean;
};
SystemInfoData: {
version?: string;
target_arch?: string;
target_os?: string;
build_profile?: string;
git_commit?: string | null;
build_time_utc?: string | null;
rustc_version?: string | null;
/** Format: int64 */
process_started_at_epoch_secs?: number;
/** Format: float */
uptime_seconds?: number;
config_path?: string;
config_hash?: string;
/** Format: int64 */
config_reload_count?: number;
/** Format: int64 */
last_config_reload_epoch_secs?: number | null;
};
FleetServerStatus: {
alias?: string;
ok?: boolean;
health_ok?: boolean;
health_http_status?: number;
/** Format: int64 */
health_latency_ms?: number;
health_error?: string;
health_revision?: string;
health?: components["schemas"]["HealthData"];
system_info_ok?: boolean;
system_info_http_status?: number;
/** Format: int64 */
system_info_latency_ms?: number;
system_info_error?: string;
system_info_revision?: string;
system_info?: components["schemas"]["SystemInfoData"];
};
FleetStatusData: {
servers?: components["schemas"]["FleetServerStatus"][];
servers_total?: number;
servers_all_ok?: number;
servers_failed?: number;
};
};
responses: never;
parameters: {
/** @description Список алиасов через запятую */
aliases: string;
};
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
+235
View File
@@ -0,0 +1,235 @@
import { PUBLIC_TELEMT_GATEWAY_URL } from '$env/static/public';
import type { components } from './aggregate.gen.js';
import type { SummaryData } from './summary-types.js';
import type {
StatsSummaryData,
TelemtErrorBody,
TelemtSuccess,
UserInfo
} from './telemt-v1.js';
export function gatewayBase(): string {
const u = PUBLIC_TELEMT_GATEWAY_URL || '';
return u.replace(/\/$/, '');
}
export type AggEnvelope<T> = {
ok: true;
generated_at: string;
partial?: boolean;
data: T;
};
export class ApiError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function parseJson(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
throw new ApiError('Ответ не JSON', res.status, text);
}
}
export async function fetchAggSummary(params?: {
aliases?: string;
top_n?: number;
}): Promise<AggEnvelope<SummaryData>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.top_n != null) q.set('top_n', String(params.top_n));
const url = `${gatewayBase()}/api/agg/summary${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) {
throw new ApiError(`summary HTTP ${res.status}`, res.status, body);
}
if (!body || body.ok !== true) {
throw new ApiError('summary: ok !== true', res.status, body);
}
return body as AggEnvelope<SummaryData>;
}
export async function fetchAggFleetStatus(params?: { aliases?: string }): Promise<
AggEnvelope<components['schemas']['FleetStatusData']>
> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
const url = `${gatewayBase()}/api/agg/fleet-status${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`fleet-status HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('fleet-status: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['FleetStatusData']>;
}
export async function fetchAggUniqueIps(params?: {
aliases?: string;
geo?: boolean;
}): Promise<AggEnvelope<components['schemas']['UniqueIPsRow'][]>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.geo === false) q.set('geo', 'false');
const url = `${gatewayBase()}/api/agg/unique-ips${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`unique-ips HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('unique-ips: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UniqueIPsRow'][]>;
}
export async function fetchAggUsers(params?: {
aliases?: string;
include_links?: boolean;
}): Promise<AggEnvelope<components['schemas']['UsersRow'][]>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.include_links) q.set('include_links', 'true');
const url = `${gatewayBase()}/api/agg/users${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (!res.ok) throw new ApiError(`users HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('users: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UsersRow'][]>;
}
export async function fetchAggUser(
username: string,
params?: { aliases?: string; include_links?: boolean }
): Promise<AggEnvelope<components['schemas']['UsersRow']>> {
const q = new URLSearchParams();
if (params?.aliases) q.set('aliases', params.aliases);
if (params?.include_links) q.set('include_links', 'true');
const path = encodeURIComponent(username);
const url = `${gatewayBase()}/api/agg/user/${path}${q.toString() ? `?${q}` : ''}`;
const res = await fetch(url);
const body = (await parseJson(res)) as Record<string, unknown> | null;
if (res.status === 404) throw new ApiError('Пользователь не найден', 404, body);
if (!res.ok) throw new ApiError(`user HTTP ${res.status}`, res.status, body);
if (!body || body.ok !== true) throw new ApiError('user: ok !== true', res.status, body);
return body as AggEnvelope<components['schemas']['UsersRow']>;
}
/** Путь к upstream без префикса /v1 — шлюз сам добавляет path_prefix. */
function apiUrl(alias: string, path: string): string {
const p = path.replace(/^\/+/, '');
return `${gatewayBase()}/api/${encodeURIComponent(alias)}/${p}`;
}
/** GET к Telemt через шлюз: путь без `/v1/` (например `health`, `stats/summary`). */
export async function fetchTelemt<T>(alias: string, path: string): Promise<TelemtSuccess<T>> {
const res = await fetch(apiUrl(alias, path));
const body = await parseJson(res);
if (!res.ok) {
const err = body as TelemtErrorBody | null;
const msg = err?.error?.message ?? `HTTP ${res.status}`;
throw new ApiError(msg, res.status, body);
}
if (!body || (body as TelemtSuccess<T>).ok !== true) {
throw new ApiError('Telemt: ok !== true', res.status, body);
}
return body as TelemtSuccess<T>;
}
export async function fetchStatsSummary(alias: string): Promise<TelemtSuccess<StatsSummaryData>> {
return fetchTelemt<StatsSummaryData>(alias, 'stats/summary');
}
export async function fetchUsersList(alias: string): Promise<TelemtSuccess<UserInfo[]>> {
return fetchTelemt<UserInfo[]>(alias, 'users');
}
export async function fetchUserOne(alias: string, username: string): Promise<TelemtSuccess<UserInfo>> {
const u = encodeURIComponent(username);
return fetchTelemt<UserInfo>(alias, `users/${u}`);
}
export async function createUser(
alias: string,
body: {
username: string;
secret?: string;
user_ad_tag?: string;
max_tcp_conns?: number;
expiration_rfc3339?: string;
data_quota_bytes?: number;
max_unique_ips?: number;
}
): Promise<TelemtSuccess<{ user: UserInfo; secret: string }>> {
const res = await fetch(apiUrl(alias, 'users'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const parsed = await parseJson(res);
if (res.status !== 201) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<{ user: UserInfo; secret: string }>).ok !== true) {
throw new ApiError('create: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<{ user: UserInfo; secret: string }>;
}
export async function patchUser(
alias: string,
username: string,
body: {
secret?: string;
user_ad_tag?: string;
max_tcp_conns?: number;
expiration_rfc3339?: string | null;
data_quota_bytes?: number;
max_unique_ips?: number;
},
ifMatch?: string | null
): Promise<TelemtSuccess<UserInfo>> {
const u = encodeURIComponent(username);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(apiUrl(alias, `users/${u}`), {
method: 'PATCH',
headers,
body: JSON.stringify(body)
});
const parsed = await parseJson(res);
if (!res.ok) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<UserInfo>).ok !== true) {
throw new ApiError('patch: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<UserInfo>;
}
export async function deleteUser(
alias: string,
username: string,
ifMatch?: string | null
): Promise<TelemtSuccess<string>> {
const u = encodeURIComponent(username);
const headers: Record<string, string> = {};
if (ifMatch) headers['If-Match'] = ifMatch;
const res = await fetch(apiUrl(alias, `users/${u}`), { method: 'DELETE', headers });
const parsed = await parseJson(res);
if (!res.ok) {
const err = parsed as TelemtErrorBody | null;
throw new ApiError(err?.error?.message ?? `HTTP ${res.status}`, res.status, parsed);
}
if (!parsed || (parsed as TelemtSuccess<string>).ok !== true) {
throw new ApiError('delete: ok !== true', res.status, parsed);
}
return parsed as TelemtSuccess<string>;
}
+30
View File
@@ -0,0 +1,30 @@
/** Соответствует internal/aggregate/types.go — SummaryData и опрос upstream. */
export interface ServerFetchResultRow {
alias: string;
ok: boolean;
http_status?: number;
latency_ms?: number;
error?: string;
revision?: string;
}
export interface TopUser {
username: string;
total_megabytes: number;
}
export interface TopUserByUniqueIPs {
username: string;
unique_ips: number;
}
export interface SummaryData {
servers: ServerFetchResultRow[];
servers_total: number;
servers_ok: number;
servers_failed: number;
fleet_total_megabytes: number;
fleet_total_connections: number;
top_users: TopUser[];
top_users_by_unique_ips: TopUserByUniqueIPs[];
}
+91
View File
@@ -0,0 +1,91 @@
/** Минимальные типы Telemt Control API (docs/API.md) для UI. */
export type TelemtSuccess<T> = { ok: true; data: T; revision?: string };
export type TelemtErrorBody = {
ok: false;
error: { code: string; message: string };
request_id?: number;
};
export interface HealthData {
status: string;
read_only: boolean;
}
export interface StatsSummaryData {
uptime_seconds: number;
connections_total: number;
connections_bad_total: number;
handshake_timeouts_total: number;
configured_users: number;
}
export interface UserLinks {
classic?: string[];
secure?: string[];
tls?: string[];
}
export interface UserInfo {
username: string;
user_ad_tag?: string | null;
max_tcp_conns?: number | null;
expiration_rfc3339?: string | null;
data_quota_bytes?: number | null;
max_unique_ips?: number | null;
current_connections: number;
active_unique_ips: number;
active_unique_ips_list?: string[];
recent_unique_ips: number;
recent_unique_ips_list?: string[];
total_octets: number;
links?: UserLinks;
}
export interface SystemInfoData {
version: string;
target_arch: string;
target_os: string;
build_profile: string;
git_commit?: string | null;
build_time_utc?: string | null;
rustc_version?: string | null;
process_started_at_epoch_secs: number;
uptime_seconds: number;
config_path: string;
config_hash: string;
config_reload_count: number;
last_config_reload_epoch_secs?: number | null;
}
export interface RuntimeGatesData {
accepting_new_connections: boolean;
conditional_cast_enabled: boolean;
me_runtime_ready: boolean;
me2dc_fallback_enabled: boolean;
use_middle_proxy: boolean;
startup_status: string;
startup_stage: string;
startup_progress_pct: number;
}
export interface DcStatus {
dc: number;
rtt_ms?: number | null;
alive_writers: number;
required_writers: number;
coverage_pct: number;
load: number;
}
export interface DcStatusData {
middle_proxy_enabled: boolean;
reason?: string | null;
dcs?: DcStatus[];
}
export interface RuntimeMePoolStateData {
generations?: Record<string, unknown>;
contour?: Record<string, unknown>;
writers_health?: Record<string, unknown>;
}