This commit is contained in:
Kevin Baensch 2022-11-03 11:05:44 +01:00
commit b852746352
13 changed files with 1612 additions and 0 deletions

44
src/caddy.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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();
}

View file

@ -0,0 +1,3 @@
import { daemon } from "daemon";
daemon("dist/app.js", [], { cwd: process.cwd() });

219
src/types.ts Normal file
View 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[]>;