♻️ refactor: split into testable modules, add test suite

This commit is contained in:
2026-07-01 13:14:41 +07:00
parent 56de27161e
commit 57e27ea87f
20 changed files with 860 additions and 478 deletions
-44
View File
@@ -1,44 +0,0 @@
import fs from "fs";
import path from "path";
const ConfigurationData: any = [];
class Configuration {
public init(): void {
const dir = path.join(process.cwd(), "configs/");
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
this.copyExampleIfNotExists("UpdaterConfig.json");
this.loadConfig("UpdaterConfig.json");
}
public loadConfig(configFileName: string): void {
const dir = path.join(process.cwd(), "configs/");
if (!fs.existsSync(path.join(dir, configFileName)))
throw new Error(`Config file ${configFileName} does not exist`);
const config = JSON.parse(fs.readFileSync(path.join(dir, configFileName)).toString());
ConfigurationData[configFileName.replace(/\.[^/.]+$/, "")] = config;
}
public getConfig(key?: string) {
if (!key) return ConfigurationData;
else {
if (ConfigurationData[key]) return ConfigurationData[key];
else throw new Error(`No configuration found for ${key}`);
}
}
private copyExampleIfNotExists(file: string): void {
const dir = path.join(process.cwd(), "configs/");
if (!fs.existsSync(path.join(dir, file))) {
fs.copyFileSync(
path.join(process.cwd(), "configs_example/", `${file.replace(".json", ".example.json")}`),
path.join(dir, file),
);
}
}
}
export default new Configuration();
-408
View File
@@ -1,408 +0,0 @@
import Logger from "../libs/Logger";
import Environment from "./Environment";
import Configuration from "./Configuration";
import axios from "axios";
import validator from "validator";
interface CloudflareConfig {
token: string;
updateInterval: number;
zone: Array<ZoneConfig>;
}
interface ZoneConfig {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
}
class Deamon {
private config: Array<CloudflareConfig> = [];
constructor() {}
public init(): void {
const _config = Configuration.getConfig("UpdaterConfig");
if (!_config) {
Logger.error("No configuration found");
return;
}
for (let cloudflareConfig of _config) {
if (!this.isCloudflareConfig(cloudflareConfig)) {
Logger.error(`Invalid configuration for cloudflare: ${JSON.stringify(cloudflareConfig)}`);
continue;
}
this.config.push(cloudflareConfig);
}
Logger.info(`Loaded ${this.config.length} cloudflare configs`);
this.start();
}
public start(): void {
Logger.info("Starting deamon");
for (const cloudflareConfig of this.config) {
this.update(cloudflareConfig);
setInterval(() => this.update(cloudflareConfig), cloudflareConfig.updateInterval * 1000);
}
}
private async update(cloudflareConfig: CloudflareConfig) {
let token = cloudflareConfig.token;
// Replace placeholders env with value
if (this.isEnviromentTokenPlaceholder(token)) {
const envTokenName = this.parseEnvironmentTokenPlaceholderName(token)!;
if (process.env[envTokenName]) {
token = process.env[envTokenName]!;
} else {
Logger.error(`Environment variable ${envTokenName} not found`);
return;
}
}
let IPv4, IPv6;
try {
IPv4 = await this.getCurrentIPv4();
IPv6 = await this.getCurrentIPv6();
} catch (err) {
Logger.error(`Unable to fetch ip address: ${err}`);
return;
}
IPv4 && Logger.info(`Current IPv4 address: ${IPv4}`);
IPv6 && Logger.info(`Current IPv6 address: ${IPv6}`);
for (const zone of cloudflareConfig.zone) {
const api = new CloudflareAPI(token, zone.id);
const records = await api
.getRecord({
type: zone.type,
name: zone.name,
})
.catch((err) => {
Logger.error(`Unable to get records for zone ${zone.id}`);
return err;
});
if (records instanceof Error) {
continue;
}
// No records found, create it
if (!records || records.length === 0) {
const newContent = zone.content
.replaceAll("{CURRENT_IPv4}", IPv4)
.replaceAll("{CURRENT_IPv6}", IPv6);
const result = await api
.createRecord({
type: zone.type,
name: zone.name,
content: IPv4,
ttl: zone.ttl,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to create record: ${err.message}`);
return err;
});
if (records instanceof Error) {
continue;
}
if (result) Logger.info(`Created [${zone.type}] (${zone.name} -> ${newContent})`);
}
// Only 1 matching records found
else if (records.length === 1) {
const record = records[0];
const newContent = zone.content
.replaceAll("{CURRENT_IPv4}", IPv4)
.replaceAll("{CURRENT_IPv6}", IPv6);
// Check if the ip is the same
if (record.content !== IPv4 && record.content !== IPv6) {
const updateResult = await api
.updateRecord({
record_id: record.id,
type: zone.type,
name: zone.name,
ttl: zone.ttl,
content: newContent,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to update record: ${err.message}`);
return err;
});
if (records instanceof Error) {
continue;
}
if (updateResult)
Logger.info(
`[${zone.type}] (${zone.name} -> ${record.content}) updated to (${zone.name} -> ${newContent})`,
);
} else if (record.ttl !== zone.ttl || record.proxied !== zone.proxied) {
const updateResult = await api
.updateRecord({
record_id: record.id,
type: zone.type,
name: zone.name,
ttl: zone.ttl,
content: newContent,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to update record: ${err.message}`);
console.log(err);
return;
});
if (updateResult)
Logger.info(
`[${zone.type}] (${zone.name} -> ${record.content}) updated proxy status/ttl (TTL: ${record.ttl} -> ${zone.ttl}) (Proxy status: ${record.proxied} -> ${zone.proxied})`,
);
} else {
Logger.info(`[${zone.type}] (${zone.name} -> ${record.content}) already up to date`);
continue;
}
}
// Many records found
else if (records.length > 1) {
Logger.error(
`Multiple records found for ${zone.type} (${zone.name}) (Multiple records are not supported right now)`,
);
continue;
}
}
}
private isCloudflareConfig(object: any): object is CloudflareConfig {
if (!this.arraysEqual(Object.keys(object), ["token", "updateInterval", "zone"])) return false;
const res =
object &&
object.token &&
typeof object.token == "string" &&
object.updateInterval &&
typeof object.updateInterval == "number";
for (let zone of object.zone) {
if (!this.isZoneConfig(zone)) return false;
}
if (!res) return false;
const token = object.token as string;
// Process the env token
if (this.isEnviromentTokenPlaceholder(token)) {
const envTokenName = this.parseEnvironmentTokenPlaceholderName(token)!;
const envValue = process.env[envTokenName];
if (!envValue) {
Logger.error(`Environment variable ${envTokenName} not found`);
return false;
}
}
return true;
}
private isZoneConfig(object: any): object is ZoneConfig {
if (!this.arraysEqual(Object.keys(object), ["id", "type", "name", "content", "ttl", "proxied"]))
return false;
const res =
object &&
object.id &&
typeof object.id == "string" &&
object.type &&
typeof object.type == "string" &&
object.name &&
typeof object.name == "string" &&
object.content &&
typeof object.content == "string" &&
object.ttl &&
typeof object.ttl == "number" &&
typeof object.proxied == "boolean";
if (!res) return false;
return true;
}
private isEnviromentTokenPlaceholder(token: string) {
return token.startsWith("{ENV_TOKEN:") && token.endsWith("}");
}
private parseEnvironmentTokenPlaceholderName(token: string) {
if (!this.isEnviromentTokenPlaceholder(token)) return null;
return token.split("{ENV_TOKEN:")[1].slice(0, -1);
}
private async getCurrentIPv4() {
const response = await axios.get("https://api.ipify.org?format=json");
if (!response.data.ip) throw new Error("Unable to fetch ip address");
if (!validator.isIP(response.data.ip, 4)) throw new Error("Invalid IP");
return response.data.ip;
}
private async getCurrentIPv6() {
const response = await axios.get("https://api64.ipify.org/?format=json");
if (!response.data.ip) throw new Error("Unable to fetch ip address");
if (!validator.isIP(response.data.ip, 6)) return null;
return response.data.ip;
}
private arraysEqual(a: any, b: any) {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
class CloudflareAPI {
private token: string;
private zoneId: string;
constructor(token: string, zoneId: string) {
this.token = token;
this.zoneId = zoneId;
}
public async getRecord({
name,
type,
content,
proxied,
page,
}: {
name?: string;
type?: string;
content?: string;
proxied?: boolean;
page?: number;
}) {
const response = await axios.get(
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/dns_records`,
{
params: {
name,
type,
perPage: 5000,
content,
proxied,
page,
},
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
},
);
if (response.data.success !== true)
throw new Error(`Unable to fetch record: ${response.data.errors[0].message}`);
return response.data.result;
}
public async createRecord({
name,
type,
content,
ttl,
proxied,
}: {
name?: string;
type?: string;
content?: string;
ttl: number;
proxied?: boolean;
}) {
const response = await axios.post(
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/dns_records`,
{
type,
name,
content,
ttl,
proxied,
},
{
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
},
);
if (response.data.success !== true)
throw new Error(`Unable to create record: ${response.data.errors[0].message}`);
return response.data.result;
}
public async updateRecord({
record_id,
name,
type,
content,
ttl,
proxied,
}: {
record_id: string;
name?: string;
type?: string;
content?: string;
ttl: number;
proxied?: boolean;
}) {
const response = await axios.put(
`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/dns_records/${record_id}`,
{
type,
name,
content,
ttl,
proxied,
},
{
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
},
);
if (response.data.success !== true)
throw new Error(`Unable to update record: ${response.data.errors[0].message}`);
return response.data.result;
}
}
export default new Deamon();
@@ -1,8 +1,8 @@
import Logger from "../libs/Logger";
import Logger from "../libs/logger";
import Environment from "./Environment";
import Configuration from "./Configuration";
import Daemon from "./Daemon";
import Environment from "./environment";
import Configuration from "./configuration";
import Updater from "./updater";
class App {
public loadConfig(): void {
@@ -14,9 +14,9 @@ class App {
Logger.log("info", "Loading environment");
Environment.init();
}
public loadDaemon(): void {
Logger.log("info", "Loading daemon");
Daemon.init();
public loadUpdater(): void {
Logger.log("info", "Loading updater");
Updater.init();
}
}
+65
View File
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
describe("Configuration", () => {
let tmpDir: string;
let originalCwd: string;
beforeEach(() => {
originalCwd = process.cwd();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ddns-config-test-"));
fs.mkdirSync(path.join(tmpDir, "configs_example"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "configs_example", "UpdaterConfig.example.json"),
JSON.stringify([{ token: "example-token", updateInterval: 60, zone: [] }]),
);
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("copies the example config and loads it on init", async () => {
const { default: Configuration } = await import(`./configuration?t=${Date.now()}-1`);
Configuration.init();
expect(Configuration.getConfig("UpdaterConfig")).toEqual([
{ token: "example-token", updateInterval: 60, zone: [] },
]);
});
test("does not overwrite an existing config file", async () => {
fs.mkdirSync(path.join(tmpDir, "configs"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "configs", "UpdaterConfig.json"),
JSON.stringify([{ token: "custom-token", updateInterval: 30, zone: [] }]),
);
const { default: Configuration } = await import(`./configuration?t=${Date.now()}-2`);
Configuration.init();
expect(Configuration.getConfig("UpdaterConfig")).toEqual([
{ token: "custom-token", updateInterval: 30, zone: [] },
]);
});
test("getConfig throws for an unknown key", async () => {
const { default: Configuration } = await import(`./configuration?t=${Date.now()}-3`);
Configuration.init();
expect(() => Configuration.getConfig("Unknown")).toThrow("No configuration found for Unknown");
});
test("loadConfig throws when the file does not exist", async () => {
const { default: Configuration } = await import(`./configuration?t=${Date.now()}-4`);
expect(() => Configuration.loadConfig("Missing.json")).toThrow(
"Config file Missing.json does not exist",
);
});
});
+49
View File
@@ -0,0 +1,49 @@
import fs from "fs";
import path from "path";
const ConfigurationData: Record<string, unknown> = {};
function configDir(): string {
return path.join(process.cwd(), "configs");
}
function configExampleDir(): string {
return path.join(process.cwd(), "configs_example");
}
class Configuration {
public init(): void {
const dir = configDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
this.copyExampleIfNotExists("UpdaterConfig.json");
this.loadConfig("UpdaterConfig.json");
}
public loadConfig(configFileName: string): void {
const filePath = path.join(configDir(), configFileName);
if (!fs.existsSync(filePath)) throw new Error(`Config file ${configFileName} does not exist`);
const config = JSON.parse(fs.readFileSync(filePath, "utf-8"));
ConfigurationData[configFileName.replace(/\.[^/.]+$/, "")] = config;
}
public getConfig<T = unknown>(key?: string): T {
if (!key) return ConfigurationData as T;
if (ConfigurationData[key]) return ConfigurationData[key] as T;
throw new Error(`No configuration found for ${key}`);
}
private copyExampleIfNotExists(file: string): void {
const target = path.join(configDir(), file);
if (!fs.existsSync(target)) {
const example = path.join(configExampleDir(), file.replace(".json", ".example.json"));
fs.copyFileSync(example, target);
}
}
}
export default new Configuration();
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
mock.module("dotenv", () => ({
config: () => ({}),
}));
describe("Environment", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = { ...originalEnv };
});
test("throws when NODE_ENV is missing", async () => {
delete process.env.NODE_ENV;
const { default: Environment } = await import(`./environment?t=${Date.now()}-1`);
expect(() => Environment.init()).toThrow(".env NODE_ENV is undefined");
});
test("throws when NODE_ENV is an invalid value", async () => {
process.env.NODE_ENV = "staging";
const { default: Environment } = await import(`./environment?t=${Date.now()}-2`);
expect(() => Environment.init()).toThrow(
'.env NODE_ENV must be either "production" or "development"',
);
});
test("succeeds and exposes NODE_ENV when valid", async () => {
process.env.NODE_ENV = "development";
const { default: Environment } = await import(`./environment?t=${Date.now()}-3`);
Environment.init();
expect(Environment.get().NODE_ENV).toBe("development");
});
});
@@ -1,7 +1,11 @@
import * as path from "path";
import * as dotenv from "dotenv";
import Logger from "../libs/Logger";
import Logger from "../libs/logger";
interface EnvironmentVariables {
NODE_ENV: string | undefined;
}
const requiredENV = ["NODE_ENV"];
@@ -9,34 +13,26 @@ class Environment {
public init(): void {
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
for (let param of requiredENV) {
for (const param of requiredENV) {
if (this.isUndefinedOrEmpty(process.env[param]))
throw new Error(`.env ${param} is undefined`);
}
// NODE_ENV Checks
if (this.get().NODE_ENV != "production" && this.get().NODE_ENV != "development")
if (this.get().NODE_ENV !== "production" && this.get().NODE_ENV !== "development")
throw new Error('.env NODE_ENV must be either "production" or "development"');
Logger.log("info", `Running in ${process.env.NODE_ENV} environment`);
}
public get(): any {
const NODE_ENV = process.env.NODE_ENV;
public get(): EnvironmentVariables {
return {
NODE_ENV,
NODE_ENV: process.env.NODE_ENV,
};
}
private isUndefinedOrEmpty(value: String | undefined): boolean {
if (typeof value === "undefined") return true;
if (value === undefined) return true;
if (value === "") return true;
return false;
private isUndefinedOrEmpty(value: string | undefined): boolean {
return value === undefined || value === "";
}
}
+176
View File
@@ -0,0 +1,176 @@
import Logger from "../libs/logger";
import Configuration from "./configuration";
import CloudflareAPI from "../libs/cloudflare-api";
import { getCurrentIPv4, getCurrentIPv6 } from "../libs/public-ip";
import {
CloudflareConfig,
isCloudflareConfig,
isEnviromentTokenPlaceholder,
parseEnvironmentTokenPlaceholderName,
} from "../libs/config-validation";
class Updater {
private readonly config: Array<CloudflareConfig> = [];
public init(): void {
const _config = Configuration.getConfig<unknown[]>("UpdaterConfig");
if (!_config) {
Logger.error("No configuration found");
return;
}
for (const cloudflareConfig of _config) {
if (
!isCloudflareConfig(cloudflareConfig, (envTokenName) =>
Logger.error(`Environment variable ${envTokenName} not found`),
)
) {
Logger.error(`Invalid configuration for cloudflare: ${JSON.stringify(cloudflareConfig)}`);
continue;
}
this.config.push(cloudflareConfig);
}
Logger.info(`Loaded ${this.config.length} cloudflare configs`);
this.start();
}
public start(): void {
Logger.info("Starting updater");
for (const cloudflareConfig of this.config) {
this.update(cloudflareConfig);
setInterval(() => this.update(cloudflareConfig), cloudflareConfig.updateInterval * 1000);
}
}
private async update(cloudflareConfig: CloudflareConfig) {
let token = cloudflareConfig.token;
// Replace placeholders env with value
if (isEnviromentTokenPlaceholder(token)) {
const envTokenName = parseEnvironmentTokenPlaceholderName(token)!;
if (process.env[envTokenName]) {
token = process.env[envTokenName]!;
} else {
Logger.error(`Environment variable ${envTokenName} not found`);
return;
}
}
let IPv4, IPv6;
try {
IPv4 = await getCurrentIPv4();
IPv6 = await getCurrentIPv6();
} catch (err) {
Logger.error(`Unable to fetch ip address: ${err}`);
return;
}
IPv4 && Logger.info(`Current IPv4 address: ${IPv4}`);
IPv6 && Logger.info(`Current IPv6 address: ${IPv6}`);
for (const zone of cloudflareConfig.zone) {
const api = new CloudflareAPI(token, zone.id);
const records = await api
.getRecord({
type: zone.type,
name: zone.name,
})
.catch((err) => {
Logger.error(`Unable to get records for zone ${zone.id}`);
return err;
});
if (records instanceof Error) {
continue;
}
// No records found, create it
if (!records || records.length === 0) {
const newContent = zone.content
.replaceAll("{CURRENT_IPv4}", IPv4)
.replaceAll("{CURRENT_IPv6}", IPv6!);
const result = await api
.createRecord({
type: zone.type,
name: zone.name,
content: IPv4,
ttl: zone.ttl,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to create record: ${err.message}`);
return err;
});
if (result) Logger.info(`Created [${zone.type}] (${zone.name} -> ${newContent})`);
}
// Only 1 matching records found
else if (records.length === 1) {
const record = records[0];
const newContent = zone.content
.replaceAll("{CURRENT_IPv4}", IPv4)
.replaceAll("{CURRENT_IPv6}", IPv6!);
// Check if the ip is the same
if (record.content !== IPv4 && record.content !== IPv6) {
const updateResult = await api
.updateRecord({
record_id: record.id,
type: zone.type,
name: zone.name,
ttl: zone.ttl,
content: newContent,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to update record: ${err.message}`);
return err;
});
if (updateResult)
Logger.info(
`[${zone.type}] (${zone.name} -> ${record.content}) updated to (${zone.name} -> ${newContent})`,
);
} else if (record.ttl !== zone.ttl || record.proxied !== zone.proxied) {
const updateResult = await api
.updateRecord({
record_id: record.id,
type: zone.type,
name: zone.name,
ttl: zone.ttl,
content: newContent,
proxied: zone.proxied,
})
.catch((err) => {
Logger.error(`Unable to update record: ${err.message}`);
console.log(err);
return;
});
if (updateResult)
Logger.info(
`[${zone.type}] (${zone.name} -> ${record.content}) updated proxy status/ttl (TTL: ${record.ttl} -> ${zone.ttl}) (Proxy status: ${record.proxied} -> ${zone.proxied})`,
);
} else {
Logger.info(`[${zone.type}] (${zone.name} -> ${record.content}) already up to date`);
continue;
}
}
// Many records found
else if (records.length > 1) {
Logger.error(
`Multiple records found for ${zone.type} (${zone.name}) (Multiple records are not supported right now)`,
);
continue;
}
}
}
}
export default new Updater();