diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6700406..f789c3e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -31,6 +31,7 @@ jobs: check: - typecheck - format + - test steps: - name: Checkout diff --git a/bun.lock b/bun.lock index 4a3489a..a481c82 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "winston": "3.19.0", }, "devDependencies": { + "@types/bun": "^1.3.14", "@types/node": "^26.0.1", "@types/validator": "^13.15.10", "prettier": "^3.9.4", @@ -25,6 +26,8 @@ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], @@ -39,6 +42,8 @@ "axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], diff --git a/package.json b/package.json index 5dd78bc..15c8241 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "winston": "3.19.0" }, "devDependencies": { + "@types/bun": "^1.3.14", "@types/node": "^26.0.1", "@types/validator": "^13.15.10", "prettier": "^3.9.4", @@ -22,7 +23,8 @@ "typecheck": "tsc --noEmit", "format": "prettier --check .", "format:fix": "prettier --write .", - "check": "bun run typecheck && bun run format", + "check": "bun run typecheck && bun run format && bun test", + "test": "bun test", "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", "build": "bun run typecheck", diff --git a/src/index.ts b/src/index.ts index 5cf1de2..12553b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -import App from "./providers/App"; +import App from "./providers/app"; App.loadConfig(); App.loadENV(); -App.loadDaemon(); +App.loadUpdater(); export default App; diff --git a/src/libs/cloudflare-api.test.ts b/src/libs/cloudflare-api.test.ts new file mode 100644 index 0000000..469d853 --- /dev/null +++ b/src/libs/cloudflare-api.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const axiosGet = mock(); +const axiosPost = mock(); +const axiosPut = mock(); + +mock.module("axios", () => ({ + default: { + get: axiosGet, + post: axiosPost, + put: axiosPut, + }, +})); + +const { default: CloudflareAPI } = await import("./cloudflare-api"); + +describe("CloudflareAPI", () => { + beforeEach(() => { + axiosGet.mockReset(); + axiosPost.mockReset(); + axiosPut.mockReset(); + }); + + test("getRecord returns the result on success", async () => { + axiosGet.mockResolvedValue({ data: { success: true, result: [{ id: "record-1" }] } }); + + const api = new CloudflareAPI("token", "zone-id"); + const result = await api.getRecord({ type: "A", name: "example.com" }); + + expect(result).toEqual([{ id: "record-1" }]); + expect(axiosGet).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/zones/zone-id/dns_records", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + + test("getRecord throws when the API reports failure", async () => { + axiosGet.mockResolvedValue({ data: { success: false, errors: [{ message: "bad request" }] } }); + + const api = new CloudflareAPI("token", "zone-id"); + await expect(api.getRecord({})).rejects.toThrow("Unable to fetch record: bad request"); + }); + + test("createRecord posts the record payload and returns the result", async () => { + axiosPost.mockResolvedValue({ data: { success: true, result: { id: "new-record" } } }); + + const api = new CloudflareAPI("token", "zone-id"); + const result = await api.createRecord({ + type: "A", + name: "example.com", + content: "1.2.3.4", + ttl: 300, + proxied: false, + }); + + expect(result).toEqual({ id: "new-record" }); + expect(axiosPost).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/zones/zone-id/dns_records", + expect.objectContaining({ type: "A", name: "example.com", content: "1.2.3.4" }), + expect.anything(), + ); + }); + + test("createRecord throws when the API reports failure", async () => { + axiosPost.mockResolvedValue({ data: { success: false, errors: [{ message: "nope" }] } }); + + const api = new CloudflareAPI("token", "zone-id"); + await expect(api.createRecord({ ttl: 300 })).rejects.toThrow("Unable to create record: nope"); + }); + + test("updateRecord puts the record payload and returns the result", async () => { + axiosPut.mockResolvedValue({ data: { success: true, result: { id: "record-1" } } }); + + const api = new CloudflareAPI("token", "zone-id"); + const result = await api.updateRecord({ + record_id: "record-1", + type: "A", + name: "example.com", + content: "1.2.3.4", + ttl: 300, + proxied: false, + }); + + expect(result).toEqual({ id: "record-1" }); + expect(axiosPut).toHaveBeenCalledWith( + "https://api.cloudflare.com/client/v4/zones/zone-id/dns_records/record-1", + expect.objectContaining({ type: "A", name: "example.com" }), + expect.anything(), + ); + }); + + test("updateRecord throws when the API reports failure", async () => { + axiosPut.mockResolvedValue({ data: { success: false, errors: [{ message: "denied" }] } }); + + const api = new CloudflareAPI("token", "zone-id"); + await expect(api.updateRecord({ record_id: "record-1", ttl: 300 })).rejects.toThrow( + "Unable to update record: denied", + ); + }); +}); diff --git a/src/libs/cloudflare-api.ts b/src/libs/cloudflare-api.ts new file mode 100644 index 0000000..0448878 --- /dev/null +++ b/src/libs/cloudflare-api.ts @@ -0,0 +1,122 @@ +import axios from "axios"; + +export default class CloudflareAPI { + private readonly token: string; + private readonly 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; + } +} diff --git a/src/libs/config-validation.test.ts b/src/libs/config-validation.test.ts new file mode 100644 index 0000000..a30c268 --- /dev/null +++ b/src/libs/config-validation.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { + arraysEqual, + isCloudflareConfig, + isEnviromentTokenPlaceholder, + isZoneConfig, + parseEnvironmentTokenPlaceholderName, +} from "./config-validation"; + +describe("arraysEqual", () => { + test("returns true for identical arrays", () => { + expect(arraysEqual(["a", "b"], ["a", "b"])).toBe(true); + }); + + test("returns false for different length arrays", () => { + expect(arraysEqual(["a"], ["a", "b"])).toBe(false); + }); + + test("returns false for different order", () => { + expect(arraysEqual(["a", "b"], ["b", "a"])).toBe(false); + }); + + test("returns false when either input is nullish", () => { + expect(arraysEqual(null, ["a"])).toBe(false); + expect(arraysEqual(["a"], undefined)).toBe(false); + }); +}); + +describe("isEnviromentTokenPlaceholder", () => { + test("recognizes a well formed placeholder", () => { + expect(isEnviromentTokenPlaceholder("{ENV_TOKEN:MY_TOKEN}")).toBe(true); + }); + + test("rejects a plain string", () => { + expect(isEnviromentTokenPlaceholder("plain-token")).toBe(false); + }); +}); + +describe("parseEnvironmentTokenPlaceholderName", () => { + test("extracts the env var name", () => { + expect(parseEnvironmentTokenPlaceholderName("{ENV_TOKEN:MY_TOKEN}")).toBe("MY_TOKEN"); + }); + + test("returns null for a non-placeholder token", () => { + expect(parseEnvironmentTokenPlaceholderName("plain-token")).toBeNull(); + }); +}); + +describe("isZoneConfig", () => { + const validZone = { + id: "zone-id", + type: "A", + name: "example.com", + content: "{CURRENT_IPv4}", + ttl: 300, + proxied: true, + }; + + test("accepts a well formed zone config", () => { + expect(isZoneConfig(validZone)).toBe(true); + }); + + test("rejects a zone config missing a key", () => { + const { proxied, ...rest } = validZone; + expect(isZoneConfig(rest)).toBe(false); + }); + + test("rejects a zone config with wrong types", () => { + expect(isZoneConfig({ ...validZone, ttl: "300" })).toBe(false); + }); +}); + +describe("isCloudflareConfig", () => { + const validConfig = { + token: "some-token", + updateInterval: 60, + zone: [ + { + id: "zone-id", + type: "A", + name: "example.com", + content: "{CURRENT_IPv4}", + ttl: 300, + proxied: true, + }, + ], + }; + + test("accepts a well formed config", () => { + expect(isCloudflareConfig(validConfig)).toBe(true); + }); + + test("rejects a config with an invalid zone", () => { + expect(isCloudflareConfig({ ...validConfig, zone: [{ id: "only-id" }] })).toBe(false); + }); + + test("rejects an env token placeholder whose variable is unset", () => { + delete process.env.MISSING_TOKEN_VAR; + const onMissing = () => {}; + expect( + isCloudflareConfig({ ...validConfig, token: "{ENV_TOKEN:MISSING_TOKEN_VAR}" }, onMissing), + ).toBe(false); + }); + + test("accepts an env token placeholder whose variable is set", () => { + process.env.SET_TOKEN_VAR = "resolved-value"; + expect(isCloudflareConfig({ ...validConfig, token: "{ENV_TOKEN:SET_TOKEN_VAR}" })).toBe(true); + delete process.env.SET_TOKEN_VAR; + }); + + test("invokes the onMissingEnvToken callback with the variable name", () => { + let received: string | undefined; + isCloudflareConfig({ ...validConfig, token: "{ENV_TOKEN:CALLBACK_VAR}" }, (name) => { + received = name; + }); + expect(received).toBe("CALLBACK_VAR"); + }); +}); diff --git a/src/libs/config-validation.ts b/src/libs/config-validation.ts new file mode 100644 index 0000000..65969b7 --- /dev/null +++ b/src/libs/config-validation.ts @@ -0,0 +1,89 @@ +export interface ZoneConfig { + id: string; + type: string; + name: string; + content: string; + ttl: number; + proxied: boolean; +} + +export interface CloudflareConfig { + token: string; + updateInterval: number; + zone: Array; +} + +export function arraysEqual(a: any, b: any): boolean { + 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; +} + +export function isEnviromentTokenPlaceholder(token: string): boolean { + return token.startsWith("{ENV_TOKEN:") && token.endsWith("}"); +} + +export function parseEnvironmentTokenPlaceholderName(token: string): string | null { + if (!isEnviromentTokenPlaceholder(token)) return null; + return token.split("{ENV_TOKEN:")[1].slice(0, -1); +} + +export function isZoneConfig(object: any): object is ZoneConfig { + if (!arraysEqual(Object.keys(object), ["id", "type", "name", "content", "ttl", "proxied"])) + return false; + + return ( + 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" + ); +} + +export function isCloudflareConfig( + object: any, + onMissingEnvToken?: (envTokenName: string) => void, +): object is CloudflareConfig { + if (!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 (!isZoneConfig(zone)) return false; + } + + if (!res) return false; + + const token = object.token as string; + + // Process the env token + if (isEnviromentTokenPlaceholder(token)) { + const envTokenName = parseEnvironmentTokenPlaceholderName(token)!; + const envValue = process.env[envTokenName]; + + if (!envValue) { + onMissingEnvToken?.(envTokenName); + return false; + } + } + + return true; +} diff --git a/src/libs/Logger.ts b/src/libs/logger.ts similarity index 100% rename from src/libs/Logger.ts rename to src/libs/logger.ts diff --git a/src/libs/public-ip.test.ts b/src/libs/public-ip.test.ts new file mode 100644 index 0000000..0760442 --- /dev/null +++ b/src/libs/public-ip.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const axiosGet = mock(); + +mock.module("axios", () => ({ + default: { get: axiosGet }, +})); + +const { getCurrentIPv4, getCurrentIPv6 } = await import("./public-ip"); + +describe("getCurrentIPv4", () => { + beforeEach(() => axiosGet.mockReset()); + + test("returns the ip when a valid IPv4 address is returned", async () => { + axiosGet.mockResolvedValue({ data: { ip: "1.2.3.4" } }); + expect(await getCurrentIPv4()).toBe("1.2.3.4"); + }); + + test("throws when no ip is returned", async () => { + axiosGet.mockResolvedValue({ data: {} }); + await expect(getCurrentIPv4()).rejects.toThrow("Unable to fetch ip address"); + }); + + test("throws when the returned ip is not a valid IPv4 address", async () => { + axiosGet.mockResolvedValue({ data: { ip: "not-an-ip" } }); + await expect(getCurrentIPv4()).rejects.toThrow("Invalid IP"); + }); +}); + +describe("getCurrentIPv6", () => { + beforeEach(() => axiosGet.mockReset()); + + test("returns the ip when a valid IPv6 address is returned", async () => { + axiosGet.mockResolvedValue({ data: { ip: "::1" } }); + expect(await getCurrentIPv6()).toBe("::1"); + }); + + test("returns null when the returned value is not a valid IPv6 address", async () => { + axiosGet.mockResolvedValue({ data: { ip: "1.2.3.4" } }); + expect(await getCurrentIPv6()).toBeNull(); + }); + + test("throws when no ip is returned", async () => { + axiosGet.mockResolvedValue({ data: {} }); + await expect(getCurrentIPv6()).rejects.toThrow("Unable to fetch ip address"); + }); +}); diff --git a/src/libs/public-ip.ts b/src/libs/public-ip.ts new file mode 100644 index 0000000..9294372 --- /dev/null +++ b/src/libs/public-ip.ts @@ -0,0 +1,22 @@ +import axios from "axios"; +import validator from "validator"; + +export async function getCurrentIPv4(): Promise { + 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; +} + +export async function getCurrentIPv6(): Promise { + 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; +} diff --git a/src/providers/Configuration.ts b/src/providers/Configuration.ts deleted file mode 100644 index 96bd587..0000000 --- a/src/providers/Configuration.ts +++ /dev/null @@ -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(); diff --git a/src/providers/Daemon.ts b/src/providers/Daemon.ts deleted file mode 100644 index 2b1c2db..0000000 --- a/src/providers/Daemon.ts +++ /dev/null @@ -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; -} - -interface ZoneConfig { - id: string; - type: string; - name: string; - content: string; - ttl: number; - proxied: boolean; -} - -class Deamon { - private config: Array = []; - - 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(); diff --git a/src/providers/App.ts b/src/providers/app.ts similarity index 50% rename from src/providers/App.ts rename to src/providers/app.ts index de6333c..65716e4 100644 --- a/src/providers/App.ts +++ b/src/providers/app.ts @@ -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(); } } diff --git a/src/providers/configuration.test.ts b/src/providers/configuration.test.ts new file mode 100644 index 0000000..328d8b8 --- /dev/null +++ b/src/providers/configuration.test.ts @@ -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", + ); + }); +}); diff --git a/src/providers/configuration.ts b/src/providers/configuration.ts new file mode 100644 index 0000000..d2e5a4b --- /dev/null +++ b/src/providers/configuration.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; + +const ConfigurationData: Record = {}; + +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(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(); diff --git a/src/providers/environment.test.ts b/src/providers/environment.test.ts new file mode 100644 index 0000000..e166062 --- /dev/null +++ b/src/providers/environment.test.ts @@ -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"); + }); +}); diff --git a/src/providers/Environment.ts b/src/providers/environment.ts similarity index 56% rename from src/providers/Environment.ts rename to src/providers/environment.ts index 171ce72..82960b5 100644 --- a/src/providers/Environment.ts +++ b/src/providers/environment.ts @@ -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 === ""; } } diff --git a/src/providers/updater.ts b/src/providers/updater.ts new file mode 100644 index 0000000..66d3240 --- /dev/null +++ b/src/providers/updater.ts @@ -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 = []; + + public init(): void { + const _config = Configuration.getConfig("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(); diff --git a/tsconfig.json b/tsconfig.json index 1ed9b50..b63db44 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,9 @@ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ /* Modules */ - "module": "commonjs", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["bun"], /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */