TODO: add Brokers for both SSLTerminationFS and Manual, complete broker manager init

Co-authored-by: Oscar Urselli <oscar0urselli@users.noreply.github.com>
This commit is contained in:
Christian Risi 2025-07-03 21:34:58 +00:00
parent f23cb0e1d2
commit 3eda2cf0a1
15 changed files with 484 additions and 52 deletions

View File

@ -1,10 +1,11 @@
import type { FSHeader, IEndpointFS } from "$lib/server/broker-utils/FileSystem/endpoints/endpoints"
import { NGINX_BASE } from "$lib/server/utils/constants"
import { NGINX_ACTIVE, NGINX_BASE, NGINX_INACTIVE, NGINX_TRACKED } from "$lib/server/utils/constants"
import { doesFileExist, isDir, loadFile } from "$lib/server/utils/filesystem-utils"
import { watch, type FSWatcher } from "node:fs"
import { parseConf } from "./utils"
import { logger } from "$lib/server/utils/logger"
import { EndpointType } from "$lib/server/enums/endpoints"
import { EndpointStatus, EndpointType } from "$lib/server/enums/endpoints"
import { validatePath } from "$lib/shared/utils/path-utils"
export class EndpointBrokerManagerFS {
@ -22,35 +23,169 @@ export class EndpointBrokerManagerFS {
return EndpointBrokerManagerFS.initialized
}
public static init() {
public static async init() {
if (EndpointBrokerManagerFS.ready) {
// UGLY: be specific
throw new Error("Broker already initialized")
}
// TODO: Read all files
// TODO: QUICK parse them
parseConf()
// TODO: Initialize a file watcher
EndpointBrokerManagerFS.watcher = EndpointBrokerManagerFS.watchNginxDirectory()
EndpointBrokerManagerFS.initialized = true
}
public static async createEndpoint(endpoint: IEndpointFS) {
if (endpoint.type === EndpointType.MANUAL) {
// UGLY: be specific
throw new Error("You can't create a manual conf automatically")
}
const REAL_PATH = `${NGINX_TRACKED}/${endpoint.path}`
if (await doesFileExist(REAL_PATH) ) {
// UGLY: be specific
throw new Error("File already existant")
}
const file = await loadFile(REAL_PATH, true)
await file.lock()
await file.write(endpoint.toConf())
await file.release()
}
public static async deleteEndpoint(path: string) {
validatePath(path)
const REAL_PATH = `${NGINX_TRACKED}/${path}`
if (! await doesFileExist(REAL_PATH)) {
// UGLY: be specific
throw new Error("This path does not exist")
}
const file = await loadFile(REAL_PATH)
await file.delete()
EndpointBrokerManagerFS.endpoints.delete(
path
)
}
public static async changeEndpoint(path: string, newEndpoint: IEndpointFS) {
validatePath(path)
const REAL_PATH = `${NGINX_TRACKED}/${path}`
if (! await doesFileExist(REAL_PATH)) {
// UGLY: more specific
throw new Error("The requested file does not exist")
}
const epFile = await loadFile(REAL_PATH)
await epFile.lock()
await epFile.write(
newEndpoint.toConf()
)
await epFile.release()
EndpointBrokerManagerFS.endpoints.set(path, newEndpoint)
const status = await EndpointBrokerManagerFS.getStatus(path)
if (status === EndpointStatus.ACTIVE) {
await this.activateEndpoint(path)
}
}
public static async activateEndpoint(path: string): Promise<boolean> {
validatePath(path)
const endpoint = EndpointBrokerManagerFS.getEndpointByPath(path)
if (!endpoint) {
// UGLY: be specific
throw new Error("This specified endpoint doesn't exist")
}
const activePath = `${NGINX_ACTIVE}/${path}`
if (! await doesFileExist(activePath)) {
const file = await loadFile(activePath, true)
await file.write(
endpoint.toConf()
)
return true
}
const file = await loadFile(activePath)
await file.lock()
const fileHash = await file.hash()
if (endpoint.hash === fileHash) {
await file.release()
return false
}
await file.write(
endpoint.toConf()
)
await file.release()
return true
}
public static async deactivateEndpoint(path: string): Promise<boolean> {
validatePath(path)
const endpoint = EndpointBrokerManagerFS.getEndpointByPath(path)
if (!endpoint) {
// UGLY: be specific
throw new Error("This specified endpoint doesn't exist")
}
const activePath = `${NGINX_ACTIVE}/${path}`
if (! await doesFileExist(activePath)) {
return false
}
const file = await loadFile(activePath)
await file.delete()
return true
}
public static getEndpointByPath(path: string): IEndpointFS | null {
const endpoint = EndpointBrokerManagerFS.endpoints.get(
validatePath(path)
const endpoint = EndpointBrokerManagerFS.endpoints.get(
path
)
@ -67,6 +202,26 @@ export class EndpointBrokerManagerFS {
return Array.from(EndpointBrokerManagerFS.endpoints.values())
}
public static async getStatus(path: string) {
validatePath(path)
const REAL_ACTIVE_PATH = `${NGINX_ACTIVE}/${path}`
const REAL_TRACKED_PATH = `${NGINX_TRACKED}/${path}`
if (await doesFileExist(REAL_ACTIVE_PATH)) {
return EndpointStatus.ACTIVE
}
if (await doesFileExist(REAL_TRACKED_PATH)) {
return EndpointStatus.INACTIVE
}
// UGLY: more specific
throw new Error("This path is non existant")
}
// MARK: private methods
@ -83,15 +238,22 @@ export class EndpointBrokerManagerFS {
recursive: true
}
const WATCHER = watch(NGINX_BASE, OPTIONS, async (eventType, filename) => {
const NGINX_TRACK = NGINX_TRACKED
const WATCHER = watch(NGINX_TRACK, OPTIONS, async (eventType, filename) => {
if (!filename) {
return
}
const RELATIVE_PATH = filename
const FULL_PATH = `${NGINX_BASE}/${RELATIVE_PATH}`
const FULL_PATH = `${NGINX_TRACK}/${RELATIVE_PATH}`
// TODO: check if it's a directory, if so, skip
if (await isDir(FULL_PATH)) {
return
return
}
// UGLY: there may be race conditions, rarely, but
@ -105,21 +267,21 @@ export class EndpointBrokerManagerFS {
case "change": {
const oldEndpoint = EndpointBrokerManagerFS.endpoints.get(
FULL_PATH
RELATIVE_PATH
)
if (!oldEndpoint) {
logger.debug(`File changed but was never tracked\nPATH: ${FULL_PATH}`, "EP Manager")
return
}
// Nothing to do, it's not managed by us
if(oldEndpoint.type === EndpointType.MANUAL) {
return
}
// Nothing to do, it's not managed by us
if (oldEndpoint.type === EndpointType.MANUAL) {
return
}
const file = await loadFile(FULL_PATH)
await file.lock()
@ -127,7 +289,7 @@ export class EndpointBrokerManagerFS {
const newHash = await file.hash()
const oldHash = oldEndpoint.hash
if (newHash === oldHash) {
// Files are equal
// or we are very unlucky
@ -143,7 +305,7 @@ export class EndpointBrokerManagerFS {
const fsHeader: FSHeader = {
name: filename!.split("/").pop()!,
stats: stats,
path: FULL_PATH,
path: RELATIVE_PATH,
hash: hash
}
@ -182,7 +344,7 @@ export class EndpointBrokerManagerFS {
// UGLY: not checking for false values
// UGLY: hints that something went wrong
EndpointBrokerManagerFS.endpoints.delete(
FULL_PATH
RELATIVE_PATH
)
}
@ -210,7 +372,7 @@ export class EndpointBrokerManagerFS {
const endpoint = parseConf(fsHeader, conf)
EndpointBrokerManagerFS.endpoints.set(
FULL_PATH,
RELATIVE_PATH,
endpoint
)

View File

@ -1,3 +1,4 @@
import type { IEndpoint } from "$lib/server/classes/endpoints/endpoints-interfaces";
import { EndpointType } from "$lib/server/enums/endpoints";
import type { FileStats } from "$lib/server/utils/filesystem-utils";
@ -28,6 +29,8 @@ export interface IEndpointFS {
headerHash(): string
toIEndpoint(): IEndpoint
}
export type FSHeader = {

View File

@ -1,6 +1,8 @@
import { EndpointType } from "$lib/server/enums/endpoints";
import type { Stats } from "fs";
import type { FSHeader, IEndpointFS } from "./endpoints";
import type { IEndpoint } from "$lib/server/classes/endpoints/endpoints-interfaces";
import { Manual } from "$lib/server/classes/endpoints/manual-endpoint";
// TODO: add broker implementation
@ -33,6 +35,14 @@ export class ManualFS implements IEndpointFS {
this.path = path
this.body = body
}
toIEndpoint(): IEndpoint {
return new Manual(
this.name,
this.path,
this.body
)
}
toConf(): string {
return this.body

View File

@ -1,10 +1,12 @@
import { EndpointType } from "$lib/server/enums/endpoints"
import { httpVersion, proxyProtocol, secureProtocol, type NginxProtocol } from "$lib/server/enums/protocols"
import { httpVersion, protocolToString, proxyProtocol, secureProtocol, stringToProtocol, type NginxProtocol } from "$lib/server/enums/protocols"
import { validatePort } from "$lib/server/utils/ports-utils"
import type { Stats } from "fs"
import type { FSHeader, IEndpointFS } from "./endpoints"
import { createHeader } from "../utils"
import { createHeader, parseDefaultHeader, parseGenericHeader } from "../utils"
import { hashUtil } from "$lib/server/utils/filesystem-utils"
import type { IEndpoint } from "$lib/server/classes/endpoints/endpoints-interfaces"
import { SSLTermination } from "$lib/server/classes/endpoints/ssl-termination-endpoint"
// TODO: add broker implementation
@ -20,7 +22,9 @@ export class SSLTerminationFS implements IEndpointFS {
public name: string
public stats: Stats
public path: string
public get path() {
return `${this.protocol}/name.conf`
}
public hash: string
public sslPort: number
public clearPort: number
@ -35,7 +39,6 @@ export class SSLTerminationFS implements IEndpointFS {
constructor(
name: string,
stats: Stats,
path: string,
hash: string,
sslPort: number,
clearPort: number,
@ -48,7 +51,6 @@ export class SSLTerminationFS implements IEndpointFS {
this.name = name
this.stats = stats
this.path = path
this.hash = hash
this.sslPort = sslPort
this.clearPort = clearPort
@ -60,6 +62,20 @@ export class SSLTerminationFS implements IEndpointFS {
}
public toIEndpoint(): IEndpoint {
return new SSLTermination(
this.name,
this.path,
this.sslPort,
this.clearPort,
this.servicePort,
this.serviceEndpoint,
this.protocol,
this.certificateURI,
this.privateKeyURI
)
}
headerHash(): string {
return hashUtil(
@ -68,7 +84,7 @@ export class SSLTerminationFS implements IEndpointFS {
}
toConf(): string {
const HEADER = this.createHeader()
const SSL_SERVER = this.createSSLServer()
const CLEAR_SERVER = this.createClearServer()
@ -78,7 +94,7 @@ export class SSLTerminationFS implements IEndpointFS {
return CONF
}
ports(): number[]{
ports(): number[] {
return [
this.sslPort,
@ -87,8 +103,78 @@ export class SSLTerminationFS implements IEndpointFS {
}
public static parseConf(fsHeader: FSHeader, conf: string) : SSLTerminationFS {
public static parseConf(fsHeader: FSHeader, conf: string): SSLTerminationFS {
// TODO: parse header
const defHeader = parseDefaultHeader(conf)
const keyValue = parseGenericHeader(conf)
const name = keyValue.get("NAME")
if (!name) {
throw new Error("Could not parse")
}
const protocol = stringToProtocol(
keyValue.get("PROTOCOL") ?? ""
)
const sslPort = Number.parseInt(
keyValue.get(
"SSL_PORT"
) ?? ""
)
validatePort(sslPort)
const clearPort = Number.parseInt(
keyValue.get(
"CLEAR_PORT"
) ?? ""
)
validatePort(clearPort)
const servicePort = Number.parseInt(
keyValue.get(
"SERVICE_PORT"
) ?? ""
)
validatePort(servicePort)
const serviceEndpoint = keyValue.get("SERVICE_ENDPOINT")
if (!serviceEndpoint) {
throw new Error("Could not parse")
}
const certificateURI = keyValue.get("CERTIFICATE_PATH")
if (!certificateURI) {
throw new Error("Could not parse")
}
const privateKeyURI = keyValue.get("KEY_PATH")
if (!privateKeyURI) {
throw new Error("Could not parse")
}
return new SSLTerminationFS(
name,
fsHeader.stats,
fsHeader.hash,
sslPort,
clearPort,
servicePort,
serviceEndpoint,
protocol,
certificateURI,
privateKeyURI
)
}
@ -101,8 +187,8 @@ export class SSLTerminationFS implements IEndpointFS {
value: this.name
},
{
key: "PROTOCOL",
value: this.protocol
key: "PROTOCOL",
value: protocolToString(this.protocol)
},
{
key: "SSL_PORT",
@ -133,14 +219,14 @@ export class SSLTerminationFS implements IEndpointFS {
}
// UGLY: refactor into a flexible method
private createSSLServer() {
private createSSLServer() {
const CLEAR_PROTOCOL = `${this.protocol}`
const HTTP_VERSION = httpVersion(this.protocol)
const PROXY_OPTION = proxyProtocol(this.protocol)
// UGLY: put to constants
let conf = [
let conf = [
"server {\n",
"\tmore_clear_headers Server;\n",
`\tlisten ${this.sslPort};`
@ -169,14 +255,14 @@ export class SSLTerminationFS implements IEndpointFS {
return conf.join("\n")
}
private createClearServer() {
private createClearServer() {
const SSL_PROTOCOL = secureProtocol(this.protocol)
const HTTP_VERSION = httpVersion(this.protocol)
const PROXY_OPTION = proxyProtocol(this.protocol)
// UGLY: put to constants
let conf = [
let conf = [
"server {\n",
"\tmore_clear_headers Server;\n",
`\tlisten ${this.clearPort};`

View File

@ -6,18 +6,26 @@ import { SSLTerminationFS } from "./endpoints/ssltermination-fs"
export const HEADER_BOUNDARY = "**********************************************************"
export const HEADER_UPPER = `/*${HEADER_BOUNDARY}`
export const HEADER_LOWER = `${HEADER_BOUNDARY}*/`
export const SPLITTING = ": "
export const DEFAULT_HEADER_LEN = 5
export const CUSTOM_HEADER_START = DEFAULT_HEADER_LEN + 1
export type HeaderKeyValueFS = {
key: string,
value: any
}
export type DefaultHeader = {
type: EndpointType,
name: string
}
export function parseConf(
fsHeader: FSHeader,
conf: string
): IEndpointFS {
const TYPE = parseDefaultConf(conf)
const TYPE = getType(conf)
switch (TYPE) {
@ -44,7 +52,7 @@ export function createHeader(endpoint: IEndpointFS, variables?: HeaderKeyValueFS
for (const variable of variables) {
header.push(
`${variable.key}: ${variable.value}`
`${variable.key}${SPLITTING}${variable.value}`
)
}
@ -54,7 +62,7 @@ export function createHeader(endpoint: IEndpointFS, variables?: HeaderKeyValueFS
}
function parseDefaultConf(conf: string): EndpointType {
function getType(conf: string): EndpointType {
/** Row (remember array order) where we find the type */
const TYPE_DATA_ROW = 2
/** What separator was used */
@ -76,4 +84,49 @@ function parseDefaultConf(conf: string): EndpointType {
return matchEndpoint(label)
}
export function parseDefaultHeader(conf: string) : DefaultHeader {
const confLines = conf.split("\n")
const HEADER = confLines.slice(0, DEFAULT_HEADER_LEN + 1)
const type = matchEndpoint(HEADER[2].split(" ")[1])
const name = HEADER[3].split(" ")[1]
return {
type: type,
name: name
}
}
export function parseGenericHeader(conf: string) : Map<string, string> {
const confLines = conf.split("\n")
const headerProbableLines = confLines.slice(CUSTOM_HEADER_START)
const keyValueMap = new Map<string, string>()
let index = 0
let parsed = false
while (!parsed) {
const line = headerProbableLines[index]
if (line === HEADER_LOWER) {
parsed = true
continue
}
const keyValue = line.split(SPLITTING)
keyValueMap.set(
keyValue[0],
keyValue[1]
)
index++
}
return keyValueMap
}

View File

@ -33,17 +33,31 @@ export class EndpointManagerApp {
return await EndpointBrokerManagerFS.deactivateEndpoint(path)
}
public static async getStatus(path: string) {
return await EndpointBrokerManagerFS.getStatus(path)
}
public static getEndpointByPath(path: string): IEndpoint | null {
// UGLY: parse
return EndpointBrokerManagerFS.getEndpointByPath(path)
const endpoint = EndpointBrokerManagerFS.getEndpointByPath(path)
if(!endpoint) {
return null
}
return endpoint.toIEndpoint()
}
public static async getAll(): Promise<IEndpoint[]> {
// UGLY: parse
return await EndpointBrokerManagerFS.getAll()
const endpoints = await EndpointBrokerManagerFS.getAll()
return endpoints.map( (endpoint) => {
return endpoint.toIEndpoint()
})
}

View File

@ -5,6 +5,12 @@ export enum EndpointType {
}
export enum EndpointStatus {
ACTIVE,
INACTIVE
}
export function matchEndpoint(label: string) {
switch(label) {

View File

@ -1,14 +1,14 @@
export enum NginxProtocol {
HTTP = "http",
HTTP2 = "http",
QUIC = "http",
GRPC = "grpc"
HTTP = "http",
HTTP2 = "http",
QUIC = "http",
GRPC = "grpc"
}
// UGLY: move these fnction into utils
export function httpVersion(protocol: NginxProtocol): number {
switch(protocol) {
switch (protocol) {
case NginxProtocol.HTTP2:
case NginxProtocol.GRPC:
return 2
@ -19,12 +19,44 @@ export function httpVersion(protocol: NginxProtocol): number {
}
}
export function protocolToString(procotol: NginxProtocol) {
switch (procotol) {
case NginxProtocol.HTTP:
return "HTTP"
case NginxProtocol.HTTP2:
return "HTTP2"
case NginxProtocol.QUIC:
return "QUIC"
case NginxProtocol.GRPC:
return "GRPC"
}
}
export function stringToProtocol(label: string) {
switch (label) {
case "HTTP":
return NginxProtocol.HTTP
case "HTTP2":
return NginxProtocol.HTTP2
case "QUIC":
return NginxProtocol.QUIC
case "GRPC":
return NginxProtocol.GRPC
default:
// UGLY: be specific
throw new Error("Protocol not found")
}
}
export function secureProtocol(protocol: NginxProtocol) {
return `${protocol}s`
}
export function proxyProtocol(protocol: NginxProtocol) {
switch(protocol) {
switch (protocol) {
case NginxProtocol.GRPC:
return "grpc_pass"
default:

View File

@ -7,4 +7,7 @@ export const SERVER_PUBLIC_KEY_PATH = `${SERVER_PRIVATE_DIR}/pub.pem`
export const DEBUG = import.meta.env.DEV
// NGINX
export const NGINX_BASE = "/etc/nginx"
export const NGINX_BASE = "/etc/nginx"
export const NGINX_INACTIVE = `${NGINX_BASE}/inactive`
export const NGINX_ACTIVE = `${NGINX_BASE}/active`
export const NGINX_TRACKED = NGINX_INACTIVE

View File

@ -79,6 +79,10 @@ export class FileHandle {
this.fd = null
}
public async delete() {
await Node.unlink(this.path)
}
}

View File

@ -0,0 +1,8 @@
export function validatePath(path: string) {
const regex = new RegExp(".*\.\..*")
if (regex.test(path)) {
// UGLY: be specific
throw new Error("Thi spath is invalid")
}
}

View File

@ -1,6 +1,21 @@
import { RequestHandler } from "@sveltejs/kit";
import { EndpointManagerApp } from "$lib/server/classes/endpoints/endpoint-manager";
import { validatePath } from "$lib/shared/utils/path-utils";
import { type RequestHandler, error } from "@sveltejs/kit";
export const PATCH: RequestHandler = ({ url }) => {
export const PATCH: RequestHandler = async ({ request }) => {
let path = await request.json();
try {
validatePath(path);
let res = await EndpointManagerApp.activateEndpoint(path);
return new Response(null, {
status: res ? 200 : 304
});
}
catch (e) {
return error(400, "Bad Request");
}
};

View File

@ -1,3 +1,9 @@
export const GET: RequestHandler = ({}) => {
import { EndpointManagerApp } from "$lib/server/classes/endpoints/endpoint-manager";
import { type RequestHandler, json } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ }) => {
let endpoints = await EndpointManagerApp.getAll();
return json(endpoints);
}

View File

@ -0,0 +1,21 @@
import { EndpointManagerApp } from "$lib/server/classes/endpoints/endpoint-manager";
import { validatePath } from "$lib/shared/utils/path-utils";
import { type RequestHandler, error } from "@sveltejs/kit";
export const PATCH: RequestHandler = async ({ request }) => {
let path = await request.json();
try {
validatePath(path);
let res = await EndpointManagerApp.deactivateEndpoint(path);
return new Response(null, {
status: res ? 200 : 304
});
}
catch (e) {
return error(400, "Bad Request");
}
};

View File

@ -0,0 +1,9 @@
import { EndpointManagerApp } from "$lib/server/classes/endpoints/endpoint-manager";
import { type RequestHandler, error, json } from "@sveltejs/kit";
export const GET: RequestHandler = async ({}) => {
let status = await EndpointManagerApp.getStatus();
return json({ status });
};