@monkeyscanjump/cloudflare-dyndns
Version:
A robust TypeScript application that automatically updates Cloudflare DNS records when your public IP address changes. Perfect for maintaining consistent domain names for home servers, WireGuard VPN, self-hosted services, or any system with a dynamic IP a
622 lines • 27.9 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CloudflareService = void 0;
const axios_1 = __importDefault(require("axios"));
const types_1 = require("../types");
const ApiConfig_1 = require("../config/ApiConfig");
const IpDetectionService_1 = require("./IpDetectionService");
/**
* Service for interacting with the Cloudflare API
* Handles zone and DNS record management with automatic discovery
*/
class CloudflareService {
/**
* Creates a new Cloudflare service instance
* @param config Application configuration
* @param logger Logger instance
*/
constructor(config, logger) {
this.config = config;
this.logger = logger;
this.apiVersion = this.config.API_VERSION || process.env.CLOUDFLARE_API_VERSION || ApiConfig_1.CloudflareApiConfig.version;
this.apiBaseUrl = this.config.API_URL || process.env.CLOUDFLARE_API_URL || ApiConfig_1.CloudflareApiConfig.baseUrl;
this.axiosConfig = {
timeout: 30000
};
}
/**
* Gets authorization headers for API requests
* @returns Headers object with authorization
*/
getHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.API_TOKEN.trim()}`
};
}
/**
* Builds full API URL with version and endpoint
* @param endpoint API endpoint path
* @returns Complete API URL
*/
getApiUrl(endpoint) {
return `${this.apiBaseUrl}/${this.apiVersion}${endpoint}`;
}
/**
* Makes an API request with fallback for endpoint changes
* @param endpoint API endpoint to call
* @param method HTTP method
* @param data Optional request body
* @returns API response data
*/
async makeApiRequest(endpoint, method = 'get', data) {
var _a;
const primaryUrl = this.getApiUrl(endpoint);
try {
this.logger.debug(`Making ${method.toUpperCase()} request to: ${primaryUrl}`);
const response = await (0, axios_1.default)({
method,
url: primaryUrl,
data,
timeout: this.axiosConfig.timeout,
headers: this.getHeaders()
});
return response.data;
}
catch (error) {
this.logger.debug(`API request failed for URL: ${primaryUrl}`);
if ((0, types_1.isAxiosError)(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 404) {
this.logger.warn(`Endpoint ${endpoint} returned 404, attempting to detect API changes`);
return this.handleApiChange(endpoint, method, data, error);
}
throw error;
}
}
/**
* Safely gets result array from API response
* @param response API response that might have undefined result
* @returns Guaranteed array (empty if result was undefined)
*/
safeGetResultArray(response) {
if (!response.success || !response.result) {
return [];
}
return response.result;
}
/**
* Safely gets single result from API response
* @param response API response that might have undefined result
* @returns Result or null if undefined
*/
safeGetResult(response) {
if (!response.success || !response.result) {
return null;
}
return response.result;
}
/**
* Handles API changes by trying alternative endpoints or versions
* @param endpoint Original endpoint that failed
* @param method HTTP method
* @param data Optional request body
* @param originalError Original error
* @returns API response from alternative endpoint
*/
async handleApiChange(endpoint, method, data, originalError) {
// Try alternative API versions
if (endpoint.includes('/zones') || endpoint.includes('/dns_records')) {
const alternativeVersions = ['v5', 'v4', 'v3'].filter(v => v !== this.apiVersion);
for (const version of alternativeVersions) {
try {
this.logger.debug(`Trying alternative API version: ${version}`);
const url = `${this.apiBaseUrl}/${version}${endpoint}`;
const response = await (0, axios_1.default)({
method,
url,
data,
timeout: this.axiosConfig.timeout,
headers: this.getHeaders()
});
if (response.status === 200) {
this.logger.info(`Successfully used API version ${version} - updating configuration`);
this.apiVersion = version;
return response.data;
}
}
catch (versionError) {
this.logger.debug(`API version ${version} failed: ${versionError.message}`);
}
}
}
// Try alternative endpoint formats
if (endpoint.includes('/dns_records')) {
const alternativeEndpoints = [
endpoint.replace('/dns_records', '/dns'),
endpoint.replace('/dns_records', '/dns_records/v2')
];
for (const altEndpoint of alternativeEndpoints) {
try {
this.logger.debug(`Trying alternative endpoint: ${altEndpoint}`);
const url = this.getApiUrl(altEndpoint);
const response = await (0, axios_1.default)({
method,
url,
data,
timeout: this.axiosConfig.timeout,
headers: this.getHeaders()
});
if (response.status === 200) {
this.logger.info(`Successfully used alternative endpoint: ${altEndpoint}`);
return response.data;
}
}
catch (endpointError) {
this.logger.debug(`Alternative endpoint failed: ${endpointError.message}`);
}
}
}
throw originalError || new Error(`API request to ${endpoint} failed`);
}
/**
* Initializes the service by discovering missing configuration
* @returns True if initialization succeeded
*/
async initialize() {
try {
this.logger.info('Initializing Cloudflare service and discovering configuration...');
this.logger.debug(`Using Cloudflare API URL: ${this.apiBaseUrl}/${this.apiVersion}`);
// Auto-detect API version if needed
if (this.config.AUTO_DETECT_API || process.env.CLOUDFLARE_AUTO_DETECT_API === 'true') {
this.logger.info('Auto-detecting Cloudflare API version...');
try {
await this.detectApiVersion();
}
catch (error) {
this.logger.warn(`API version detection failed: ${error.message}. Using default version ${this.apiVersion}`);
}
}
// Step 1: Discover Zone ID if missing
if (!this.config.ZONE_ID) {
this.logger.info('No Zone ID provided, attempting to discover zones...');
const zoneId = await this.lookupZoneId();
if (zoneId) {
this.logger.info(`Using Zone ID: ${zoneId}`);
this.config.ZONE_ID = zoneId;
}
else {
this.logger.error('Could not automatically determine Zone ID. Please provide ZONE_ID manually.');
return false;
}
}
// Step 2: Build FQDN from domain and subdomain
if (this.config.DOMAIN && this.config.SUBDOMAIN && !this.config.FQDN) {
this.config.FQDN = `${this.config.SUBDOMAIN}.${this.config.DOMAIN}`;
this.logger.info(`Using FQDN: ${this.config.FQDN}`);
}
// Step 3: Look up FQDN from Record ID if we have it
if (!this.config.FQDN && this.config.RECORD_ID) {
this.logger.info('Looking up FQDN from Record ID...');
const fqdn = await this.lookupFqdnFromRecordId();
if (fqdn) {
this.config.FQDN = fqdn;
this.logger.info(`Using FQDN: ${fqdn}`);
// Extract domain and subdomain from FQDN
const parts = fqdn.split('.');
if (parts.length >= 2) {
this.config.SUBDOMAIN = parts[0];
this.config.DOMAIN = parts.slice(1).join('.');
this.logger.debug(`Extracted subdomain: ${this.config.SUBDOMAIN}, domain: ${this.config.DOMAIN}`);
}
}
}
// Step 4: Find or create record based on FQDN
if (!this.config.RECORD_ID && this.config.FQDN) {
this.logger.info(`No Record ID provided, searching for ${this.config.FQDN}...`);
const recordId = await this.lookupRecordId();
if (recordId) {
this.logger.info(`Found Record ID: ${recordId}`);
this.config.RECORD_ID = recordId;
}
else {
this.logger.info(`No existing DNS record found for ${this.config.FQDN}. Creating one now...`);
const newRecordId = await this.createDnsRecord();
if (newRecordId) {
this.logger.info(`Created new DNS record with ID: ${newRecordId}`);
this.config.RECORD_ID = newRecordId;
}
else {
this.logger.error(`Failed to create DNS record for ${this.config.FQDN}.`);
return false;
}
}
}
// Step 5: Only find a suitable record if we have no other option
if (!this.config.RECORD_ID) {
this.logger.info('No record specifics provided, attempting to find a suitable A record...');
const recordInfo = await this.findSuitableRecord();
if (recordInfo) {
this.config.RECORD_ID = recordInfo.id;
this.config.FQDN = recordInfo.name;
// Extract domain and subdomain
const parts = recordInfo.name.split('.');
if (parts.length >= 2) {
this.config.SUBDOMAIN = parts[0];
this.config.DOMAIN = parts.slice(1).join('.');
}
this.logger.info(`Using A record: ${this.config.FQDN} (ID: ${this.config.RECORD_ID})`);
}
else {
this.logger.error('Could not find any suitable DNS records. Please create an A record first or provide more specific configuration.');
return false;
}
}
// Final validation
if (!this.config.RECORD_ID || !this.config.ZONE_ID) {
this.logger.error('Could not determine which DNS record to update. Please provide either:');
this.logger.error('1. API_TOKEN and DOMAIN and SUBDOMAIN values');
this.logger.error('2. API_TOKEN and FQDN value');
this.logger.error('3. API_TOKEN and ZONE_ID and RECORD_ID values');
return false;
}
this.logger.info('Configuration successfully initialized');
this.logger.debug(`Using configuration: Zone ID: ${this.config.ZONE_ID}, Record ID: ${this.config.RECORD_ID}, FQDN: ${this.config.FQDN}`);
return true;
}
catch (error) {
this.logger.error(`Error initializing service: ${error.message}`);
return false;
}
}
/**
* Detects which Cloudflare API version is available
* @returns Detected API version string
*/
async detectApiVersion() {
var _a;
const versions = ['v4', 'v5', 'v3'];
for (const version of versions) {
try {
const testUrl = `${this.apiBaseUrl}/${version}/zones`;
this.logger.debug(`Testing API version ${version} with URL: ${testUrl}`);
const response = await axios_1.default.get(testUrl, {
headers: this.getHeaders(),
timeout: 5000
});
if (response.status === 200 && ((_a = response.data) === null || _a === void 0 ? void 0 : _a.success) === true) {
this.logger.info(`Detected working API version: ${version}`);
this.apiVersion = version;
return version;
}
}
catch (error) {
this.logger.debug(`API version ${version} check failed: ${error.message}`);
}
}
this.logger.warn(`Could not auto-detect API version, using default: ${this.apiVersion}`);
return this.apiVersion;
}
/**
* Looks up the Zone ID for the user's domain
* @returns Zone ID or null if not found
*/
async lookupZoneId() {
try {
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.listZones + '?per_page=50';
const response = await this.makeApiRequest(endpoint);
const zones = this.safeGetResultArray(response);
if (zones.length > 0) {
// Single zone case
if (zones.length === 1) {
const zone = zones[0];
this.logger.info(`Found single zone: ${zone.name} (${zone.id})`);
return zone.id;
}
// Try to match by domain name
if (this.config.DOMAIN) {
const matchingZone = zones.find((zone) => zone.name.toLowerCase() === this.config.DOMAIN.toLowerCase());
if (matchingZone) {
this.logger.info(`Found matching zone for domain ${this.config.DOMAIN}: ${matchingZone.id}`);
return matchingZone.id;
}
}
// Show available zones
this.logger.warn('Multiple zones found for this API token. Please specify ZONE_ID in configuration.');
this.logger.info('Available zones:');
zones.forEach((zone, index) => {
this.logger.info(`${index + 1}. ${zone.name} (ID: ${zone.id})`);
});
// Only use first zone if we have to
if (this.config.DOMAIN) {
this.logger.warn(`Could not find exact match for ${this.config.DOMAIN}. Manual configuration required.`);
return null;
}
else {
this.logger.warn(`Using first zone by default: ${zones[0].name} (${zones[0].id})`);
return zones[0].id;
}
}
else {
this.logger.error('No zones found for this API token. Please verify your token has the correct permissions.');
}
return null;
}
catch (error) {
this.logger.error(`Failed to lookup zones: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return null;
}
}
/**
* Looks up the DNS record ID based on FQDN
* @returns Record ID or null if not found
*/
async lookupRecordId() {
try {
if (!this.config.ZONE_ID) {
this.logger.error('Zone ID is required to look up record ID');
return null;
}
if (!this.config.FQDN) {
this.logger.error('FQDN is required to look up record ID');
return null;
}
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.listRecords(this.config.ZONE_ID) + `?type=A&name=${this.config.FQDN}`;
const response = await this.makeApiRequest(endpoint);
const records = this.safeGetResultArray(response);
if (records.length > 0) {
return records[0].id;
}
else {
this.logger.warn(`No DNS A records found for ${this.config.FQDN}`);
return null;
}
}
catch (error) {
this.logger.error(`Failed to lookup record ID: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return null;
}
}
/**
* Finds a suitable A record to update if specific details aren't provided
* Only called when no DOMAIN/SUBDOMAIN is configured
* @returns Record information or null if no suitable record found
*/
async findSuitableRecord() {
// SAFETY ENHANCEMENT: If FQDN is configured, don't select other records
if (this.config.FQDN) {
this.logger.warn(`FQDN ${this.config.FQDN} is configured, but no matching record found.`);
this.logger.warn('Will not attempt to update any other records to avoid mistakes.');
return null;
}
try {
if (!this.config.ZONE_ID) {
this.logger.error('Zone ID is required to find suitable records');
return null;
}
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.listRecords(this.config.ZONE_ID) + '?type=A&per_page=100';
const response = await this.makeApiRequest(endpoint);
const records = this.safeGetResultArray(response);
if (records.length > 0) {
// List all available records
this.logger.info(`Found ${records.length} A records:`);
records.forEach((record, index) => {
this.logger.info(`${index + 1}. ${record.name} (${record.content})`);
});
// Try to find a subdomain record
const subdomain = records.find((record) => {
const zoneName = record.zone_name || '';
return record.name !== zoneName && !record.name.includes('*');
});
if (subdomain) {
this.logger.info(`Selected subdomain record: ${subdomain.name}`);
return { id: subdomain.id, name: subdomain.name };
}
// First record fallback
this.logger.info(`Using first A record: ${records[0].name}`);
return {
id: records[0].id,
name: records[0].name
};
}
else {
this.logger.warn('No DNS A records found in this zone');
return null;
}
}
catch (error) {
this.logger.error(`Failed to find suitable records: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return null;
}
}
/**
* Looks up the FQDN from a record ID
* @returns FQDN or null if not found
*/
async lookupFqdnFromRecordId() {
try {
if (!this.config.ZONE_ID || !this.config.RECORD_ID) {
return null;
}
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.getRecord(this.config.ZONE_ID, this.config.RECORD_ID);
const response = await this.makeApiRequest(endpoint);
const record = this.safeGetResult(response);
if (record) {
return record.name;
}
return null;
}
catch (error) {
this.logger.error(`Failed to lookup FQDN from record ID: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return null;
}
}
/**
* Creates a new DNS record with the current IP address
* @returns New record ID or null if creation failed
*/
async createDnsRecord() {
var _a;
try {
const ipService = new IpDetectionService_1.IpDetectionService(this.logger, this.config.IP_SERVICES);
const currentIp = await ipService.detectIp();
if (!currentIp) {
this.logger.error('Could not detect current IP address to create DNS record');
return null;
}
if (!this.config.ZONE_ID) {
this.logger.error('Zone ID is required to create a DNS record');
return null;
}
const data = {
type: 'A',
name: this.config.FQDN,
content: currentIp,
ttl: this.config.TTL || 120,
proxied: this.config.PROXIED || false
};
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.listRecords(this.config.ZONE_ID);
const response = await this.makeApiRequest(endpoint, 'post', data);
const record = this.safeGetResult(response);
if (record) {
this.logger.info(`Successfully created DNS record for ${this.config.FQDN} pointing to ${currentIp}`);
return record.id;
}
else {
const errors = ((_a = response.errors) === null || _a === void 0 ? void 0 : _a.map(e => e.message).join(', ')) || 'Unknown error';
this.logger.error(`Failed to create DNS record: ${errors}`);
return null;
}
}
catch (error) {
this.logger.error(`Error creating DNS record: ${error.message}`);
return null;
}
}
/**
* Verifies that the API credentials are valid
* @returns True if credentials are valid
*/
async verifyCredentials() {
try {
// Try token verify endpoint first
try {
const verifyEndpoint = '/user/tokens/verify';
const response = await this.makeApiRequest(verifyEndpoint);
if (response.success) {
this.logger.info('Successfully verified Cloudflare API credentials');
return true;
}
}
catch (error) {
this.logger.debug(`Token verify failed: ${error.message}. Trying fallback verification.`);
}
// Fallback to zones endpoint
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.listZones + '?per_page=1';
const response = await this.makeApiRequest(endpoint);
if (response.success === true) {
this.logger.info('Successfully verified Cloudflare API credentials using zones endpoint');
return true;
}
else {
this.logger.error('API credentials verification failed: API returned unsuccessful response');
return false;
}
}
catch (error) {
this.logger.error(`Failed to verify API credentials: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return false;
}
}
/**
* Updates the DNS record with a new IP address
* @param newIp New IP address to set
* @returns True if update was successful
*/
async updateDnsRecord(newIp) {
var _a;
if (!this.config.ZONE_ID || !this.config.RECORD_ID) {
this.logger.error('Zone ID and Record ID are required to update a DNS record');
return false;
}
const data = {
type: 'A',
name: this.config.FQDN,
content: newIp,
ttl: this.config.TTL,
proxied: this.config.PROXIED
};
this.logger.info(`Updating DNS record for ${this.config.FQDN} to ${newIp} (TTL: ${this.config.TTL}, Proxied: ${this.config.PROXIED})`);
const endpoint = ApiConfig_1.CloudflareApiConfig.endpoints.updateRecord(this.config.ZONE_ID, this.config.RECORD_ID);
try {
const response = await this.retryOperation(() => this.makeApiRequest(endpoint, 'put', data));
if (response.success) {
this.logger.info(`DNS record for ${this.config.FQDN} successfully updated to ${newIp}`);
return true;
}
else {
const errors = ((_a = response.errors) === null || _a === void 0 ? void 0 : _a.map(err => err.message).join('; ')) || 'Unknown error';
this.logger.error(`Failed to update DNS record: ${errors}`);
this.logger.debug(`Cloudflare API response: ${JSON.stringify(response)}`);
return false;
}
}
catch (error) {
this.logger.error(`API error updating DNS record: ${error.message}`);
if ((0, types_1.isAxiosError)(error) && error.response) {
this.logger.debug(`API Response Error: ${JSON.stringify(error.response.data)}`);
}
return false;
}
}
/**
* Retries an operation with exponential backoff
* @param operation Function to retry
* @returns Result of the operation
*/
async retryOperation(operation) {
var _a, _b;
const maxRetries = this.config.RETRY_ATTEMPTS;
const retryDelay = this.config.RETRY_DELAY;
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
}
catch (error) {
lastError = error;
if ((0, types_1.isAxiosError)(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 429) {
// Rate limit handling
const retryAfter = ((_b = error.response.headers) === null || _b === void 0 ? void 0 : _b['retry-after'])
? parseInt(error.response.headers['retry-after'], 10) * 1000
: retryDelay;
this.logger.warn(`Rate limit hit. Retrying in ${retryAfter / 1000} seconds (Attempt ${attempt}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, retryAfter));
}
else {
this.logger.warn(`Operation failed. Retrying in ${retryDelay / 1000} seconds (Attempt ${attempt}/${maxRetries}): ${error.message}`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
throw lastError || new Error('Operation failed after retries');
}
}
exports.CloudflareService = CloudflareService;
//# sourceMappingURL=CloudflareService.js.map