mirror of
https://github.com/YuzuZensai/Cloudflare-DDNS-Updater.git
synced 2026-07-29 17:00:56 +00:00
♻️ refactor: split into testable modules, add test suite
This commit is contained in:
@@ -31,6 +31,7 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
- typecheck
|
- typecheck
|
||||||
- format
|
- format
|
||||||
|
- test
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"prettier": "^3.9.4",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,7 @@
|
|||||||
"winston": "3.19.0"
|
"winston": "3.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.9.4",
|
||||||
@@ -22,7 +23,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"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",
|
"dev": "bun --watch src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
"build": "bun run typecheck",
|
"build": "bun run typecheck",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import App from "./providers/App";
|
import App from "./providers/app";
|
||||||
|
|
||||||
App.loadConfig();
|
App.loadConfig();
|
||||||
App.loadENV();
|
App.loadENV();
|
||||||
App.loadDaemon();
|
App.loadUpdater();
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ZoneConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import validator from "validator";
|
||||||
|
|
||||||
|
export async function getCurrentIPv4(): Promise<string> {
|
||||||
|
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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
@@ -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 Environment from "./environment";
|
||||||
import Configuration from "./Configuration";
|
import Configuration from "./configuration";
|
||||||
import Daemon from "./Daemon";
|
import Updater from "./updater";
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
public loadConfig(): void {
|
public loadConfig(): void {
|
||||||
@@ -14,9 +14,9 @@ class App {
|
|||||||
Logger.log("info", "Loading environment");
|
Logger.log("info", "Loading environment");
|
||||||
Environment.init();
|
Environment.init();
|
||||||
}
|
}
|
||||||
public loadDaemon(): void {
|
public loadUpdater(): void {
|
||||||
Logger.log("info", "Loading daemon");
|
Logger.log("info", "Loading updater");
|
||||||
Daemon.init();
|
Updater.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
@@ -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 path from "path";
|
||||||
import * as dotenv from "dotenv";
|
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"];
|
const requiredENV = ["NODE_ENV"];
|
||||||
|
|
||||||
@@ -9,34 +13,26 @@ class Environment {
|
|||||||
public init(): void {
|
public init(): void {
|
||||||
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||||
|
|
||||||
for (let param of requiredENV) {
|
for (const param of requiredENV) {
|
||||||
if (this.isUndefinedOrEmpty(process.env[param]))
|
if (this.isUndefinedOrEmpty(process.env[param]))
|
||||||
throw new Error(`.env ${param} is undefined`);
|
throw new Error(`.env ${param} is undefined`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NODE_ENV Checks
|
// 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"');
|
throw new Error('.env NODE_ENV must be either "production" or "development"');
|
||||||
|
|
||||||
Logger.log("info", `Running in ${process.env.NODE_ENV} environment`);
|
Logger.log("info", `Running in ${process.env.NODE_ENV} environment`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(): any {
|
public get(): EnvironmentVariables {
|
||||||
const NODE_ENV = process.env.NODE_ENV;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUndefinedOrEmpty(value: String | undefined): boolean {
|
private isUndefinedOrEmpty(value: string | undefined): boolean {
|
||||||
if (typeof value === "undefined") return true;
|
return value === undefined || value === "";
|
||||||
|
|
||||||
if (value === undefined) return true;
|
|
||||||
|
|
||||||
if (value === "") return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
+3
-1
@@ -25,7 +25,9 @@
|
|||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "commonjs",
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun"],
|
||||||
/* Specify what module code is generated. */
|
/* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
|||||||
Reference in New Issue
Block a user