Init
This commit is contained in:
commit
b852746352
13 changed files with 1612 additions and 0 deletions
44
src/caddy.ts
Normal file
44
src/caddy.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Caddy, ContainerData } from "./types";
|
||||
|
||||
const caddyConfig: Caddy = {
|
||||
admin: {
|
||||
disabled: false,
|
||||
config: {
|
||||
persist: false,
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
http: {
|
||||
http_port: 80,
|
||||
https_port: 443,
|
||||
servers: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// shorthand for use in functions
|
||||
const servers = caddyConfig.apps!.http!.servers;
|
||||
|
||||
export function removeServer(id: string) {
|
||||
if (id in servers) {
|
||||
delete servers[id];
|
||||
}
|
||||
}
|
||||
|
||||
export function addServer(container: ContainerData) {
|
||||
servers[container.Id] = {
|
||||
listen: container.HostNames,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCaddy(): Promise<void> {
|
||||
const request = await fetch("google.com");
|
||||
await request.json();
|
||||
return;
|
||||
}
|
||||
|
||||
function mkCaddyHost(container: ContainerData, network: string) {
|
||||
return {
|
||||
listen: [],
|
||||
};
|
||||
}
|
8
src/const.ts
Normal file
8
src/const.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import * as path from "node:path";
|
||||
|
||||
const baseConfPath = "./Config";
|
||||
export const caddyConfPath = path.join(baseConfPath, "Caddyfile");
|
||||
export const hostConfPath = path.join(baseConfPath, "hosts");
|
||||
export const hostConfBakExt = "orig";
|
||||
export const dockerCaddyName = "caddy2";
|
||||
export const dockerNetwork = "proxy";
|
12
src/daemon.d.ts
vendored
Normal file
12
src/daemon.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
declare module "daemon" {
|
||||
export function daemon(
|
||||
script: string,
|
||||
args: string[],
|
||||
opt?: {
|
||||
stdout?: string,
|
||||
stderr?: string,
|
||||
env?: Record<string, string>,
|
||||
cwd?: string
|
||||
}
|
||||
);
|
||||
}
|
168
src/docker.ts
Normal file
168
src/docker.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import { Docker } from "node-docker-api";
|
||||
import { dockerCaddyName, dockerNetwork, caddyConfPath } from "./const.js";
|
||||
import {
|
||||
CaddyState,
|
||||
ContainerData,
|
||||
ContainerNetwork,
|
||||
ContainerOverride,
|
||||
ImageOverride,
|
||||
NetworkData,
|
||||
NetworkOverride,
|
||||
} from "./types.js";
|
||||
|
||||
export let networks: Record<string, NetworkData> = {};
|
||||
export let containers: Record<string, ContainerData> = {};
|
||||
|
||||
export async function update() {
|
||||
const dockerSock = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
await Promise.all([
|
||||
updateNetworkData(dockerSock),
|
||||
updateContainerData(dockerSock),
|
||||
]);
|
||||
const caddyState = await getCaddyState(dockerSock);
|
||||
if (caddyState !== "running") await startCaddy(dockerSock, caddyState);
|
||||
}
|
||||
|
||||
async function updateNetworkData(socket: Docker): Promise<void> {
|
||||
networks = {};
|
||||
|
||||
for (const network of (await socket.network.list({
|
||||
all: true,
|
||||
})) as unknown as NetworkOverride[]) {
|
||||
const data = network.data;
|
||||
// Note: There is no guarantee that Network names don't overlap.
|
||||
// There are lots of potential issues connected to non unique names so we will throw an error if this is not the case
|
||||
// See the 'CheckDuplicate' argument here:
|
||||
// https://docs.docker.com/engine/api/v1.41/#tag/Network/operation/NetworkCreate
|
||||
if (data.Name in networks)
|
||||
throw new Error(`ERROR: Duplicate docker network name: ${data.Name}`);
|
||||
networks[data.Name] = {
|
||||
Id: data.Id,
|
||||
Name: data.Name,
|
||||
Driver: data.Driver,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function updateContainerData(socket: Docker): Promise<void> {
|
||||
containers = {};
|
||||
// The Docker-Node Library is somewhat old and has inacurate/missing typing
|
||||
for (const container of (await socket.container.list({
|
||||
all: true,
|
||||
})) as unknown as ContainerOverride[]) {
|
||||
const data = container.data;
|
||||
|
||||
const networks = Object.values(data.NetworkSettings.Networks).map(
|
||||
(netData: ContainerNetwork) => {
|
||||
return [
|
||||
netData.NetworkID,
|
||||
{
|
||||
NetworkID: netData.NetworkID,
|
||||
IPAddress: netData.IPAddress,
|
||||
},
|
||||
] as [string, ContainerNetwork];
|
||||
}
|
||||
);
|
||||
containers[data.Id] = {
|
||||
Id: data.Id,
|
||||
Ports: Object.values(data.Ports).map((p) => `${p.PrivatePort}/${p.Type}`),
|
||||
Name: data.Names[0] ?? "Unnamed",
|
||||
HostNames: data.Names,
|
||||
State: data.State,
|
||||
Networks: Object.fromEntries(networks),
|
||||
Labels: data.Labels,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function getCaddyState(socket: Docker): Promise<CaddyState> {
|
||||
const container = getContainerByName(`/${dockerCaddyName}`);
|
||||
if (container) {
|
||||
// Check if container is part of the configured network
|
||||
const networkCheck = networks[dockerNetwork].Id in container.Networks;
|
||||
return networkCheck ? container.State : "wrongnetwork";
|
||||
}
|
||||
const hasCaddy = (
|
||||
(await socket.image.list()) as unknown as ImageOverride[]
|
||||
).some((image) => image?.data?.RepoTags?.includes("caddy:latest"));
|
||||
return hasCaddy ? "nocontainer" : "noimage";
|
||||
}
|
||||
|
||||
function getContainerByName(containerName: string) {
|
||||
for (const container of Object.values(containers)) {
|
||||
if (container.Name === containerName) return container;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNetworkByName(networkName: string) {
|
||||
for (const network of Object.values(networks)) {
|
||||
if (network.Name === networkName) return network;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ensureNetwork(socket: Docker) {
|
||||
if (!Object.values(networks).some((net) => net.Name === dockerNetwork)) {
|
||||
await socket.network.create({ Name: dockerNetwork });
|
||||
await updateNetworkData(socket);
|
||||
}
|
||||
}
|
||||
|
||||
async function addContainerToNetwork(
|
||||
socket: Docker,
|
||||
container: ContainerData,
|
||||
network: NetworkData
|
||||
) {
|
||||
if (!(network.Id in container.Networks)) {
|
||||
await socket.network.get(network.Id).connect({ Container: container.Id });
|
||||
await updateContainerData(socket);
|
||||
}
|
||||
}
|
||||
|
||||
async function startCaddy(socket: Docker, caddyState: CaddyState) {
|
||||
await ensureNetwork(socket);
|
||||
|
||||
switch (caddyState) {
|
||||
case "noimage":
|
||||
await socket.image.create({}, { fromImage: "caddy", tag: "latest" });
|
||||
// Create Container in shared network
|
||||
case "nocontainer":
|
||||
// Docker Documentation can be found here:
|
||||
// https://docs.docker.com/engine/api/v1.41/#tag/Container/operation/ContainerCreate
|
||||
await socket.container.create({
|
||||
name: dockerCaddyName,
|
||||
network: dockerNetwork,
|
||||
Image: "caddy",
|
||||
HostConfig: {
|
||||
PortBindings: {
|
||||
"80": [{ HostPort: "80" }],
|
||||
"443/tcp": [{ HostIp: "0.0.0.0", HostPort: "443" }],
|
||||
"443/udp": [{ HostIp: "0.0.0.0", HostPort: "443" }],
|
||||
"2019/tcp": [{ HostIp: "0.0.0.0", HostPort: "2019" }],
|
||||
},
|
||||
Binds: [`${caddyConfPath}:/etc/caddy/Caddyfile`],
|
||||
},
|
||||
});
|
||||
await updateContainerData(socket);
|
||||
case "wrongnetwork":
|
||||
await addContainerToNetwork(
|
||||
socket,
|
||||
getContainerByName(`/${dockerCaddyName}`)!,
|
||||
getNetworkByName(dockerNetwork)!
|
||||
);
|
||||
case "exited":
|
||||
const id = getContainerByName(`/${dockerCaddyName}`)?.Id!;
|
||||
await socket.container.get(id)?.start();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCaddy() {
|
||||
// Create fresh docker connection (mitigate possible exceptions with the original connection and can be called separately)
|
||||
const dockerSock = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const container = getContainerByName(`/${dockerCaddyName}`);
|
||||
if (container?.State === "running")
|
||||
await dockerSock.container.get(container.Id).stop();
|
||||
}
|
120
src/hosts.ts
Normal file
120
src/hosts.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { hostConfPath, hostConfBakExt } from "./const.js";
|
||||
import { HostData, ContainerData } from "./types.js";
|
||||
|
||||
let hasChanged = false;
|
||||
const hostData: HostData = {};
|
||||
let initialHostData: HostData = {};
|
||||
|
||||
function checkFile(path: string) {
|
||||
if (!existsSync(path))
|
||||
throw new Error(`No such file or directory: ${hostConfPath}`);
|
||||
// Throws error for missing file permissions
|
||||
accessSync(path, constants.R_OK | constants.W_OK);
|
||||
}
|
||||
|
||||
export function writeConfig(): boolean {
|
||||
// prevent unnecessary rewrites
|
||||
if (!hasChanged) return false;
|
||||
const stringData = (
|
||||
Object.entries(hostData) as Array<[string, string[]]>
|
||||
).reduce((prev, [curAddr, curHosts]) => {
|
||||
return `${prev}${curAddr} ${curHosts.join(" ")}\n`;
|
||||
}, "");
|
||||
writeFileSync(hostConfPath, stringData);
|
||||
hasChanged = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read initial data from host config backup
|
||||
function readConfig() {
|
||||
const textContent = readFileSync(`${hostConfPath}.${hostConfBakExt}`, {
|
||||
encoding: "utf8",
|
||||
}) as string;
|
||||
// NOTE: maybe properly detect EOL-Style if it causes problems
|
||||
// using Unix for now
|
||||
for (const line of textContent.split("\n")) {
|
||||
//skip empty lines and comments
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const { address, hosts } = line.match(/^(?<address>[^\ ]*) (?<hosts>.*)$/)
|
||||
?.groups ?? { address: "172.0.0.1", hosts: "localhost" };
|
||||
if (!(address in hostData)) hostData[address] = hosts.split(" ");
|
||||
else hostData[address] = hostData[address].concat(hosts.split(" "));
|
||||
}
|
||||
initialHostData = structuredClone(hostData);
|
||||
}
|
||||
|
||||
export function init() {
|
||||
// Boilerplate init code
|
||||
// Check permissions for host and backup file
|
||||
// conditionally create backup file
|
||||
checkFile(hostConfPath);
|
||||
try {
|
||||
checkFile(`${hostConfPath}.${hostConfBakExt}`);
|
||||
} catch (err) {
|
||||
if ((err as Error).message.startsWith("No such file or directory:"))
|
||||
copyFileSync(hostConfPath, `${hostConfPath}.${hostConfBakExt}`);
|
||||
else throw err;
|
||||
}
|
||||
|
||||
// load data
|
||||
readConfig();
|
||||
}
|
||||
|
||||
function addHost(hosts: string | string[], address = "127.0.0.1") {
|
||||
const hostList = typeof hosts === "string" ? hosts.split(" ") : hosts;
|
||||
if (!hostData[address]?.some((addr) => hostList.includes(addr))) {
|
||||
hostData[address] = (hostData[address] ?? []).concat(hostList);
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
function removeHost(hosts: string | string[], address = "127.0.0.1") {
|
||||
if (address in hostData) {
|
||||
const hostList = typeof hosts === "string" ? hosts.split(" ") : hosts;
|
||||
hostData[address] = hostData[address].filter(
|
||||
(host) => !hostList.includes(host)
|
||||
);
|
||||
if (hostData[address].length === 0) delete hostData[address];
|
||||
hasChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function update(dockerHosts: ContainerData[], networkID: string) {
|
||||
// Create copy of host list to diff for disconnected containers at the end
|
||||
const removedHosts = structuredClone(hostData);
|
||||
|
||||
// Keep all original hosts in list
|
||||
for (const address of Object.keys(initialHostData)) {
|
||||
removedHosts[address] = removedHosts[address].filter(
|
||||
(hostName) => !initialHostData[address].includes(hostName)
|
||||
);
|
||||
}
|
||||
|
||||
// Add new containers
|
||||
for (const container of dockerHosts) {
|
||||
if (container.State !== "running" || !(networkID in container.Networks))
|
||||
continue;
|
||||
const indexAt = removedHosts["127.0.0.1"]?.findIndex(
|
||||
(host) => host === container.Name
|
||||
);
|
||||
if (indexAt !== -1) {
|
||||
removedHosts["127.0.0.1"].splice(indexAt, 1);
|
||||
continue;
|
||||
}
|
||||
addHost(container.Name);
|
||||
}
|
||||
|
||||
// Remove Containers
|
||||
for (const address of Object.keys(removedHosts)) {
|
||||
if (removedHosts[address]?.length > 0)
|
||||
removeHost(removedHosts[address], address);
|
||||
}
|
||||
}
|
43
src/reverseProxy.ts
Normal file
43
src/reverseProxy.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import process from "node:process";
|
||||
import * as docker from "./docker.js";
|
||||
import * as hosts from "./hosts.js";
|
||||
import { dockerNetwork } from "./const.js";
|
||||
|
||||
let running = true;
|
||||
const stopRunning = () => {
|
||||
running = false;
|
||||
};
|
||||
|
||||
// Signal handlers to terminate min loop
|
||||
process.on("SIGTERM", stopRunning);
|
||||
process.on("SIGINT", stopRunning);
|
||||
|
||||
hosts.init();
|
||||
docker.update().then(() => {
|
||||
hosts.writeConfig();
|
||||
console.log("Startup Done");
|
||||
idleMain();
|
||||
});
|
||||
|
||||
async function idleMain() {
|
||||
// Main Idle Loop
|
||||
// Checks Docker for changes every ten seconds
|
||||
// SIGTERM and SIGINT signals can be processed during the timeout so the process can exit gracefully
|
||||
let idleCount = 0;
|
||||
const proxyNetworkID = docker.networks[dockerNetwork].Id;
|
||||
|
||||
while (running) {
|
||||
if (idleCount === 0) {
|
||||
await docker.update();
|
||||
hosts.update(Object.values(docker.containers), proxyNetworkID);
|
||||
if (hosts.writeConfig()) {
|
||||
//update caddy
|
||||
}
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
idleCount = (idleCount + 1) % 10;
|
||||
}
|
||||
|
||||
// Cleanup Code goes here (docker shutdown)
|
||||
await docker.stopCaddy();
|
||||
}
|
3
src/reverseProxyDaemon.ts
Normal file
3
src/reverseProxyDaemon.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { daemon } from "daemon";
|
||||
|
||||
daemon("dist/app.js", [], { cwd: process.cwd() });
|
219
src/types.ts
Normal file
219
src/types.ts
Normal file
|
@ -0,0 +1,219 @@
|
|||
export type NetType = "Bridge" | "Host" | "none" | "null";
|
||||
|
||||
export interface NetworkData {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Driver: NetType;
|
||||
}
|
||||
|
||||
export interface ContainerData {
|
||||
Id: string;
|
||||
Name: string;
|
||||
State: "exited" | "running";
|
||||
Networks: Record<string, ContainerNetwork>;
|
||||
HostNames?: string[];
|
||||
Ports: string[];
|
||||
Labels: {
|
||||
HostName?: string;
|
||||
PortMap?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ContainerNetwork = {
|
||||
NetworkID: string;
|
||||
IPAddress: string;
|
||||
};
|
||||
|
||||
export type ContainerOverride = {
|
||||
data: {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
State: "exited" | "running";
|
||||
NetworkSettings: {
|
||||
Networks: Record<string, ContainerNetwork>;
|
||||
};
|
||||
Labels: Record<string, string>;
|
||||
Ports: {
|
||||
IP?: string;
|
||||
PrivatePort: number;
|
||||
PublicPort?: number;
|
||||
Type: "tcp" | "udp";
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export type NetworkOverride = {
|
||||
data: {
|
||||
Name: string;
|
||||
Id: string;
|
||||
Driver: NetType;
|
||||
Scope: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageOverride = {
|
||||
data: {
|
||||
Containers: number;
|
||||
Created: number;
|
||||
Id: string;
|
||||
Labels: null | Record<string, string>;
|
||||
ParentId: string;
|
||||
RepoDigests: string[];
|
||||
RepoTags: string[];
|
||||
SharedSize: number;
|
||||
Size: number;
|
||||
VirtualSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type CaddyState =
|
||||
| "running"
|
||||
| "exited"
|
||||
| "nocontainer"
|
||||
| "noimage"
|
||||
| "wrongnetwork";
|
||||
|
||||
export interface CaddyHttpServer {
|
||||
listen?: string[];
|
||||
// "listener_wrappers"?: [{•••}],
|
||||
read_timeout?: number;
|
||||
read_header_timeout?: number;
|
||||
write_timeout?: number;
|
||||
idle_timeout?: number;
|
||||
keepalive_interval?: number;
|
||||
max_header_bytes?: number;
|
||||
routes?: {
|
||||
group?: string;
|
||||
// "match"?: [{••• }],
|
||||
// "handle"?: [{••• }],
|
||||
terminal?: boolean;
|
||||
}[];
|
||||
errors?: {
|
||||
routes?: [
|
||||
{
|
||||
group?: string;
|
||||
// "match"?: [{••• }],
|
||||
// "handle"?: [{••• }],
|
||||
terminal?: boolean;
|
||||
}
|
||||
];
|
||||
};
|
||||
tls_connection_policies?: [
|
||||
{
|
||||
// "match"?: {••• },
|
||||
certificate_selection?: {
|
||||
serial_number?: [{}];
|
||||
subject_organization?: string[];
|
||||
public_key_algorithm?: number;
|
||||
any_tag?: string[];
|
||||
all_tags?: string[];
|
||||
};
|
||||
cipher_suites?: string[];
|
||||
curves?: string[];
|
||||
alpn?: string[];
|
||||
protocol_min?: string;
|
||||
protocol_max?: string;
|
||||
client_authentication?: {
|
||||
trusted_ca_certs?: string[];
|
||||
trusted_ca_certs_pem_files?: string[];
|
||||
trusted_leaf_certs?: string;
|
||||
// "verifiers"?: [{••• }],
|
||||
mode?: string;
|
||||
};
|
||||
default_sni?: string;
|
||||
insecure_secrets_log?: string;
|
||||
}
|
||||
];
|
||||
automatic_https?: {
|
||||
disable?: boolean;
|
||||
disable_redirects?: boolean;
|
||||
disable_certificates?: boolean;
|
||||
skip?: string[];
|
||||
skip_certificates?: string[];
|
||||
ignore_loaded_certificates?: boolean;
|
||||
};
|
||||
strict_sni_host?: boolean;
|
||||
logs?: {
|
||||
default_logger_name?: string;
|
||||
logger_names?: Record<string, string>;
|
||||
skip_hosts?: string[];
|
||||
skip_unmapped_hosts?: boolean;
|
||||
should_log_credentials?: boolean;
|
||||
};
|
||||
protocols?: string[];
|
||||
metrics?: {};
|
||||
}
|
||||
|
||||
export interface CaddyHttp {
|
||||
http_port: number;
|
||||
https_port?: number;
|
||||
grace_period?: number;
|
||||
shutdown_delay?: number;
|
||||
servers: Record<string, CaddyHttpServer>;
|
||||
}
|
||||
|
||||
export interface CaddyFileSystem {
|
||||
module: "file_system";
|
||||
root: string;
|
||||
}
|
||||
|
||||
export interface Caddy {
|
||||
admin?: {
|
||||
/** If true, the admin endpoint will be completely disabled. */
|
||||
disabled?: boolean;
|
||||
/** The address to which the admin endpoint's listener should bind itself. */
|
||||
listen?: string;
|
||||
enforce_origin?: boolean;
|
||||
origins?: string[];
|
||||
config?: {
|
||||
persist?: boolean;
|
||||
// "load"?: {••• }
|
||||
};
|
||||
identity?: {
|
||||
identifiers?: string[];
|
||||
// "issuers"?: [{••• }]
|
||||
};
|
||||
remote?: {
|
||||
listen?: string;
|
||||
access_control?: [
|
||||
{
|
||||
public_keys?: string[];
|
||||
permissions?: [
|
||||
{
|
||||
paths?: string[];
|
||||
methods?: string[];
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
logging?: {
|
||||
sink?: {
|
||||
// writer?: { }
|
||||
};
|
||||
logs?: Record<
|
||||
string,
|
||||
{
|
||||
// "writer"?: { },
|
||||
// "encoder"?: { },
|
||||
level?: string;
|
||||
sampling?: {
|
||||
interval?: number;
|
||||
first?: number;
|
||||
thereafter?: number;
|
||||
};
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
storage?: {
|
||||
file_system?: CaddyFileSystem;
|
||||
};
|
||||
apps?: {
|
||||
http?: CaddyHttp;
|
||||
};
|
||||
}
|
||||
|
||||
export type HostData = Record<string, string[]>;
|
Loading…
Add table
Add a link
Reference in a new issue