fix: correct DNS sync results and configuration retention (#739)

* fix: correct DNS sync results and configuration retention

Keep address families independent, reject failed update responses, preserve complete IPv6 addresses, and retain Studio drafts and effective extra settings. Add offline regressions and wire Studio tests into documentation builds.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(provider): make disk cache regressions deterministic

Align the cache fixture wall clock and file mtime so Windows Python 3.12 clock granularity cannot turn a freshly saved cache into a future-dated file. Keep real disk reads, all cache assertions, production expiry policy and CI matrices unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
New Future
2026-09-06 12:45:46 +08:00
committed by GitHub
co-authored by Copilot App NewFuture
parent 087b18bcdc
commit 27f0f1c0ac
22 changed files with 1123 additions and 103 deletions
+21 -21
View File
@@ -75,7 +75,7 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No
更新IP并变更DNS记录
"""
_raise_if_cancelled(cancelled)
if not domains:
if not domains or index_rule is False:
return None
ip_type = "4" if record_type == "A" else "6"
@@ -84,7 +84,7 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No
logger.error("Fail to get %s address!", ip_type)
return False
update_success = False
update_success = True
for domain in domains:
_raise_if_cancelled(cancelled)
@@ -92,21 +92,22 @@ def update_ip(dns, cache, index_rule, domains, record_type, config, cancelled=No
cache_key = "{}:{}".format(domain, record_type)
if cache and cache.get(cache_key) == address:
logger.info("%s[%s] address not changed, using cache: %s", domain, record_type, address)
update_success = True
else:
try:
result = dns.set_record(
domain, address, record_type=record_type, ttl=config.ttl, line=config.line, **config.extra
)
if result:
logger.warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address)
update_success = True
if isinstance(cache, dict):
cache[cache_key] = address
else:
logger.error("Failed to update %s record for %s", record_type, domain)
except Exception as e:
logger.exception("Failed to update %s record for %s: %s", record_type, domain, e)
continue
try:
result = dns.set_record(
domain, address, record_type=record_type, ttl=config.ttl, line=config.line, **config.extra
)
if result:
logger.warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address)
if isinstance(cache, dict):
cache[cache_key] = address
else:
update_success = False
logger.error("Failed to update %s record for %s", record_type, domain)
except Exception as e:
update_success = False
logger.exception("Failed to update %s record for %s: %s", record_type, domain, e)
_raise_if_cancelled(cancelled)
return update_success
@@ -125,10 +126,9 @@ def run(config, cancelled=None):
config.id, config.token, endpoint=config.endpoint, logger=logger, proxy=config.proxy, ssl=config.ssl
)
cache = Cache.new(config.cache, config.md5(), logger, config.cache_max_age)
return (
update_ip(dns, cache, config.index4, config.ipv4, "A", config, cancelled=cancelled) is not False
and update_ip(dns, cache, config.index6, config.ipv6, "AAAA", config, cancelled=cancelled) is not False
)
result4 = update_ip(dns, cache, config.index4, config.ipv4, "A", config, cancelled=cancelled)
result6 = update_ip(dns, cache, config.index6, config.ipv6, "AAAA", config, cancelled=cancelled)
return result4 is not False and result6 is not False
def main():
+9 -1
View File
@@ -15,7 +15,12 @@ ssl_verify = "auto"
IPV4_REG = r"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])"
# IPV6正则
# https://community.helpsystems.com/forums/intermapper/miscellaneous-topics/5acc4fcf-fa83-e511-80cf-0050568460e4
IPV6_REG = r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))" # noqa: E501
# Keep full IPv6 literals while allowing compact text labels such as IP:<address>.
IPV6_REG = (
r"(?:(?<![0-9A-Fa-f:.])|(?<=[^0-9A-Fa-f:.]:))"
r"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))" # noqa: E501
r"(?![0-9A-Fa-f:]|\.[0-9])"
)
# 公网IPv4 API列表,按优先级排序
PUBLIC_IPV4_APIS = [
@@ -68,6 +73,9 @@ def _open(url, reg):
debug("open: %s", url)
# IP 模块重试3次
response = request("GET", url, verify=ssl_verify, retries=2)
if response.status is None or not 200 <= response.status < 300:
error("IP query failed with HTTP status %s", response.status)
return None
res = response.body
debug("response: %s", res)
match = compile(reg).search(res)
+7 -1
View File
@@ -102,6 +102,8 @@ class SimpleProvider(object):
accept = TYPE_JSON # type: str | None
# Decode Response as JSON by default
decode_response = True # type: bool
# Opt in only when the provider cannot interpret non-2xx responses as normal API results.
_require_http_success = False # type: bool
# Description
remark = "Managed by [DDNS](https://ddns.newfuture.cc)"
@@ -190,7 +192,8 @@ class SimpleProvider(object):
Any: 解析后的响应内容
Raises:
RuntimeError: 当响应状态码为400/401或5xx(服务器错误)时抛出异常
RuntimeError: 响应状态码为400/401/403或5xx时抛出异常;
启用_require_http_success的服务商会拒绝所有非2xx状态
"""
method = method.upper()
@@ -263,6 +266,9 @@ class SimpleProvider(object):
else:
raise RuntimeError("服务器错误 [{}]: {}".format(status_code, response.reason))
if self._require_http_success and not (200 <= status_code < 300):
raise RuntimeError("HTTP error [{}]: {}".format(status_code, response.reason))
self.logger.debug("response:\n%s", res)
if not self.decode_response:
return res
+1
View File
@@ -20,6 +20,7 @@ class CallbackProvider(SimpleProvider):
endpoint = "" # CallbackProvider uses id as URL, no fixed API endpoint
content_type = TYPE_JSON
decode_response = False # Callback response is not JSON, it's a custom response
_require_http_success = True
def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
"""
+7 -5
View File
@@ -10,6 +10,7 @@ from ._base import TYPE_JSON, BaseProvider, join_domain
class CloudflareProvider(BaseProvider):
endpoint = "https://api.cloudflare.com"
content_type = TYPE_JSON
_require_http_success = True
def _validate(self):
if not self.token:
@@ -31,11 +32,12 @@ class CloudflareProvider(BaseProvider):
params = {k: v for k, v in params.items() if v is not None} # 过滤掉None参数
data = self._http(method, "/client/v4/zones" + action, headers=headers, params=params)
if data and data.get("success"):
return data.get("result") # 返回结果或原始数据
else:
self.logger.warning("Cloudflare API error: %s", data.get("errors", "Unknown error"))
return data
if isinstance(data, dict) and data.get("success") is True:
return data.get("result") # 成功的空列表仍表示没有匹配记录
errors = data.get("errors", "Unknown error") if isinstance(data, dict) else "Invalid response"
self.logger.warning("Cloudflare API error: %s", errors)
# Do not let a failed lookup become an empty result and trigger record creation.
raise RuntimeError("Cloudflare API request failed")
def _query_zone_id(self, domain):
"""https://developers.cloudflare.com/api/resources/zones/methods/list/"""
@@ -171,6 +171,7 @@ interface StoredDraft {
validationInput: string
baselineValidationInput: string
validatorTouched: boolean
validationFromFile: boolean
view: {
selectedProviderIndex: number
activeSection: SectionKey
@@ -603,6 +604,7 @@ const providerQuery = ref('')
const providerSearchAnnouncement = ref('')
const validationInput = ref('')
const validatorTouched = ref(false)
const validationFromFile = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const providerPickerTrigger = ref<HTMLButtonElement | null>(null)
const providerSearchInput = ref<HTMLInputElement | null>(null)
@@ -1053,12 +1055,12 @@ function buildProvider(provider: ProviderState): JsonObject {
else if (provider.logLevel) output.log = { level: provider.logLevel }
const extra = parseObjectText(provider.extraText)
if (extra && Object.keys(extra).length) output.extra = extra
if (extra) output.extra = extra
return output
}
function buildConfiguration(): JsonObject {
const globalExtra = parseObjectText(globalState.extraText) || {}
const globalExtra = parseObjectText(globalState.extraText)
const output: JsonObject = {
$schema: SCHEMA_URL,
providers: providers.value.map((provider) => buildProvider(provider)),
@@ -1094,7 +1096,7 @@ function buildConfiguration(): JsonObject {
}
if (Object.keys(log).length) output.log = log
if (Object.keys(globalExtra).length) output.extra = globalExtra
if (globalExtra) output.extra = globalExtra
return output
}
@@ -1357,6 +1359,7 @@ function persistDraft() {
validationInput: validationInput.value,
baselineValidationInput: baselineValidationInput.value,
validatorTouched: validatorTouched.value,
validationFromFile: validationFromFile.value,
view: currentDraftView(),
}
window.sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
@@ -1383,6 +1386,7 @@ function restoreLegacyDraft(value: Record<string, unknown>): boolean {
if (!(config.providers as JsonValue[]).some((provider) => isPlainObject(provider))) return false
if (!loadConfigurationIntoBuilder(config, false, c.value.legacyDraftRestored)) return false
validationInput.value = typeof value.validationInput === 'string' ? value.validationInput : ''
validationFromFile.value = false
validatorTouched.value = validationInput.value !== baselineValidationInput.value
if (validationInput.value.trim()) inspectorTab.value = 'validate'
return true
@@ -1405,6 +1409,8 @@ function restoreCurrentDraft(value: Record<string, unknown>): boolean {
typeof value.validatorTouched === 'boolean'
? value.validatorTouched
: validationInput.value !== baselineValidationInput.value
// Older drafts did not record an external saved copy; keep them unexported.
validationFromFile.value = draftBoolean(value, 'validationFromFile')
restoreDraftView(value.view)
showToast(c.value.draftRestored)
return true
@@ -1445,6 +1451,7 @@ watch(
[
editorSnapshot,
validationInput,
validationFromFile,
selectedUid,
activeSection,
inspectorTab,
@@ -2964,6 +2971,7 @@ function handleProviderPickerKeydown(event: KeyboardEvent) {
async function selectInspectorTab(tab: InspectorTab, focusTab = false) {
if (tab === 'validate' && !validatorTouched.value) {
validationInput.value = generatedJson.value
validationFromFile.value = false
}
inspectorTab.value = tab
if (!focusTab) return
@@ -3317,6 +3325,7 @@ function resetBuilder() {
providerQuery.value = ''
validationInput.value = ''
validatorTouched.value = false
validationFromFile.value = false
Object.assign(globalState, makeGlobalState())
nextTick(() => {
markValidationHandled()
@@ -3371,6 +3380,7 @@ async function importFile(event: Event) {
try {
validationInput.value = await file.text()
validatorTouched.value = true
validationFromFile.value = true
inspectorTab.value = 'validate'
showToast(c.value.imported)
} catch {
@@ -3401,21 +3411,31 @@ function sslFromValue(value: unknown, fallback: SslMode): { mode: SslMode; path:
return { mode: fallback, path: '' }
}
function collectExtra(value: Record<string, unknown>, known: Set<string>): JsonObject {
const extra: JsonObject = isPlainObject(value.extra) ? { ...(value.extra as JsonObject) } : {}
function flattenExtraFields(value: Record<string, unknown>, known: Set<string>): JsonObject {
const fields: JsonObject = {}
Object.keys(value).forEach((key) => {
if (known.has(key)) return
const nested = value[key]
if (key.startsWith('extra_')) {
extra[key.slice(6)] = nested as JsonValue
} else if (isPlainObject(nested)) {
if (key === 'extra') {
if (isPlainObject(nested)) fields.extra = nested as JsonObject
return
}
if (known.has(key)) return
if (isPlainObject(nested)) {
Object.keys(nested).forEach((nestedKey) => {
extra[`${key}_${nestedKey}`] = nested[nestedKey] as JsonValue
fields[`${key}_${nestedKey}`] = nested[nestedKey] as JsonValue
})
} else {
extra[key] = nested as JsonValue
fields[key] = nested as JsonValue
}
})
return fields
}
function collectExtra(fields: JsonObject): JsonObject {
const extra: JsonObject = isPlainObject(fields.extra) ? { ...(fields.extra as JsonObject) } : {}
Object.keys(fields).forEach((key) => {
if (key !== 'extra') extra[key.startsWith('extra_') ? key.slice(6) : key] = fields[key]
})
return extra
}
@@ -3440,7 +3460,10 @@ function logFieldState(
}
}
function providerStateFromObject(value: Record<string, unknown>): ProviderState {
function providerStateFromObject(
value: Record<string, unknown>,
inheritedExtraFields: JsonObject = {},
): ProviderState {
const providerName =
typeof value.provider === 'string'
? value.provider
@@ -3449,7 +3472,13 @@ function providerStateFromObject(value: Record<string, unknown>): ProviderState
: ''
const cache = cacheFromValue(value.cache, 'inherit')
const ssl = sslFromValue(value.ssl, 'inherit')
const extra = collectExtra(value, PROVIDER_KNOWN_KEYS)
const extraFields = flattenExtraFields(value, PROVIDER_KNOWN_KEYS)
// Match the runtime's flatten -> inherit/override -> collect order. Canonical
// provider.extra replaces global.extra, so any override must retain its full
// effective JSON fields; a provider without overrides can still inherit.
const extraText = Object.keys(extraFields).length
? JSON.stringify(collectExtra({ ...inheritedExtraFields, ...extraFields }), null, 2)
: ''
return {
uid: nextProviderUid++,
provider: providerName,
@@ -3481,7 +3510,7 @@ function providerStateFromObject(value: Record<string, unknown>): ProviderState
cachePath: cache.path,
cacheMaxAge: typeof value.cache_max_age === 'number' ? String(value.cache_max_age) : '',
logLevel: logLevelFromObject(value),
extraText: Object.keys(extra).length ? JSON.stringify(extra, null, 2) : '',
extraText,
revealToken: false,
}
}
@@ -3492,10 +3521,13 @@ function loadConfigurationIntoBuilder(
message: string,
): boolean {
const importedProviders: ProviderState[] = []
let importedGlobalExtra: JsonObject = {}
let importedGlobalExtraText = ''
if (Array.isArray(value.providers)) {
importedGlobalExtra = collectExtra(value, ROOT_KNOWN_KEYS)
const globalExtraFields = flattenExtraFields(value, ROOT_KNOWN_KEYS)
if (Object.keys(globalExtraFields).length) {
importedGlobalExtraText = JSON.stringify(collectExtra(globalExtraFields), null, 2)
}
const inherited: Record<string, unknown> = {}
;['ipv4', 'ipv6', 'index4', 'index6', 'ttl', 'line'].forEach((key) => {
if (key in value) inherited[key] = value[key]
@@ -3503,10 +3535,13 @@ function loadConfigurationIntoBuilder(
value.providers.forEach((provider) => {
if (isPlainObject(provider)) {
importedProviders.push(
providerStateFromObject({
...inherited,
...provider,
}),
providerStateFromObject(
{
...inherited,
...provider,
},
globalExtraFields,
),
)
}
})
@@ -3515,7 +3550,8 @@ function loadConfigurationIntoBuilder(
;['dns', 'id', 'token', 'endpoint', 'ipv4', 'ipv6', 'index4', 'index6', 'ttl', 'line'].forEach((key) => {
if (key in value) providerObject[key] = value[key]
})
providerObject.extra = collectExtra(value, ROOT_KNOWN_KEYS)
const extraFields = flattenExtraFields(value, ROOT_KNOWN_KEYS)
if (Object.keys(extraFields).length) providerObject.extra = collectExtra(extraFields)
importedProviders.push(providerStateFromObject(providerObject))
}
@@ -3549,9 +3585,7 @@ function loadConfigurationIntoBuilder(
logDatefmt: logDatefmt.value,
logDatefmtPresent: logDatefmt.present,
logDatefmtNull: logDatefmt.isNull,
extraText: Object.keys(importedGlobalExtra).length
? JSON.stringify(importedGlobalExtra, null, 2)
: '',
extraText: importedGlobalExtraText,
})
activeSection.value = 'provider'
inspectorTab.value = 'preview'
@@ -3561,7 +3595,6 @@ function loadConfigurationIntoBuilder(
runtimeAdvancedOpen.value = hasRuntimeAdvancedSettings.value
nextTick(() => {
if (markClean) {
markValidationHandled()
commitBaseline()
} else {
scheduleDraftPersistence()
@@ -3574,7 +3607,10 @@ function loadConfigurationIntoBuilder(
function applyToBuilder() {
const parsedValue = parsedValidation.value.value
if (!validationCanApply.value || !isPlainObject(parsedValue)) return
loadConfigurationIntoBuilder(parsedValue, true, c.value.applied)
if (loadConfigurationIntoBuilder(parsedValue, validationFromFile.value, c.value.applied)) {
markValidationHandled()
validationFromFile.value = false
}
}
async function useRuntimeCredential(field: 'id' | 'token') {
@@ -4939,7 +4975,10 @@ async function useRuntimeCredential(field: 'id' | 'token') {
:aria-invalid="validationErrors.length ? 'true' : undefined"
aria-describedby="studio-validator-hint studio-validator-status"
:placeholder="c.validatorPlaceholder"
@input="validatorTouched = true"
@input="
validatorTouched = true;
validationFromFile = false
"
></textarea>
<div class="validator-foot">
<span>{{ validationLineCount }} {{ c.lineCount }}</span>
+4
View File
@@ -136,6 +136,8 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
* **单个域名**:`"ddns.newfuture.cc"`
* **多个域名**:`["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"]`
IPv4 和 IPv6 独立处理:一族被禁用或获取地址失败,不会阻止另一族更新。同步会继续尝试其余域名,但只要任一已启用的记录更新失败,单次运行的 CLI 命令就返回非零退出码,Web/MCP 则报告同步失败。启用缓存时,已成功的记录仍会保留缓存。
### index4-index6
`index4`和`index6`参数用于指定获取IP地址的方式,可以使用以下值:
@@ -152,6 +154,8 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
* `"cmd:xxx"`:执行指定命令并使用其输出作为IP
* `"shell:xxx"`:使用系统shell运行命令并使用其输出作为IP
`public` 和 `url:` 规则只从成功的 HTTP 响应中提取地址;错误响应会继续回退到后续 API 或规则,不会使用错误页面里的 IP。IPv6 地址支持包含点分 IPv4 尾部的完整表示,例如 `2001:db8::192.0.2.10`。
配置示例:
```jsonc
+6
View File
@@ -17,3 +17,9 @@ FORM: 已有文档世界中的完整 Operate 页面;采用“持续可见的
-->
<ConfigStudio />
::: details 导入、草稿与扩展字段
在页面内创建、粘贴或修改的配置,点击“载入编辑器”不等于导出。浏览器允许临时存储时,尚未导出的完整草稿(包括凭据)会保存在当前标签页,刷新后可恢复;复制或下载后才标记为已导出。未经修改的文件导入仍以原文件为已保存基线。旧草稿若没有记录文件来源,会保守地保留为未导出状态。
服务商扩展字段留空表示继承全局 JSON `extra`;填写 `{}` 表示显式空覆盖,不会清除环境变量提供的扩展字段。导入会按运行时优先级处理平铺字段、`extra_` 别名和嵌套 `extra`。为保留有效值,必要时会将继承的字段显式展开到服务商的扩展字段中;这些显式值之后会覆盖全局设置。
:::
+4
View File
@@ -134,6 +134,8 @@ Supported formats:
* **Single domain**: `"ddns.newfuture.cc"`
* **Multiple domains**: `["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"]`
IPv4 and IPv6 are processed independently: disabling one family or failing to discover its address does not prevent the other family from updating. Synchronization continues with the remaining domains, but any failed enabled record makes a one-shot CLI run exit nonzero; Web/MCP report the synchronization failure. When caching is enabled, successfully updated records are still cached.
### index4-index6
The `index4` and `index6` parameters are used to specify the method for obtaining IP addresses. The following values can be used:
@@ -150,6 +152,8 @@ Supported types:
* `"cmd:xxx"`: Execute specified command and use its output as IP
* `"shell:xxx"`: Use system shell to run command and use its output as IP
The `public` and `url:` rules extract addresses only from successful HTTP responses. An error response falls back to the next API or rule instead of using an IP found in its error page. IPv6 addresses retain complete dotted-IPv4 tails, such as `2001:db8::192.0.2.10`.
Configuration examples:
```jsonc
+6
View File
@@ -17,3 +17,9 @@ FORM: A complete Operate page inside the established documentation world, using
-->
<ConfigStudio />
::: details Imports, drafts, and custom fields
For configurations created, pasted, or edited on this page, **Load into builder** does not count as an export. When temporary browser storage is available, complete unexported drafts (including credentials) are saved in the current tab and can be restored after a refresh; copying or downloading marks them as exported. An unchanged file import still uses the original file as its saved baseline. Older drafts without file-origin information are conservatively retained as unexported.
Leave provider custom fields blank to inherit the global JSON `extra`; enter `{}` for an explicit empty override, which does not clear custom fields supplied by environment variables. Imports resolve flat fields, `extra_` aliases, and nested `extra` in runtime precedence order. To preserve effective values, inherited fields are expanded into explicit provider custom fields when needed; those explicit values subsequently override global settings.
:::
+6
View File
@@ -49,6 +49,12 @@ Return to [All Providers](/en/providers/) to see more options
| GET | token is empty | Use URL query parameters |
| POST | token is not empty | Use JSON request body |
### HTTP Responses and Caching
Callback accepts only final HTTP statuses from 200 to 299 as success. Empty response bodies, such as HTTP 204 responses, are allowed.
HTTP 404, 429, and all other non-2xx statuses are failures. The current IP is not cached as a successful update, so a later synchronization will retry.
Callback does not interpret custom business errors in an HTTP 200 response body. To report an update failure, the callback endpoint should return an appropriate non-2xx HTTP status.
### GET Request Example
```jsonc
+6
View File
@@ -172,6 +172,12 @@ ddns -c config.json --debug
- **Record creation failed**: Check record format and TTL values, confirm permission settings
- **Rate limit exceeded**: API call frequency exceeded, reduce request frequency
### Error Handling and Caching
A Cloudflare request is considered successful only when its HTTP status is 2xx and the API returns `success: true`.
A final HTTP error such as 429, an API response with `success: false`, or an invalid response makes the domain update fail. The pending IP is not cached as a successful update, so a later synchronization will retry.
A failed zone or record lookup stops processing that domain; it is not treated as a missing record and cannot trigger record creation. A successful query with an empty record list still follows the normal creation flow.
## Support and Resources
- [Cloudflare Developer Documentation](https://developers.cloudflare.com/)
+3
View File
@@ -4,9 +4,12 @@
"description": "DDNS Documentation Website",
"type": "module",
"scripts": {
"test:studio": "node --test tests/config-studio.test.mjs",
"docs:dev": "vitepress dev .",
"predocs:build": "npm run test:studio",
"docs:build": "vitepress build .",
"docs:preview": "vitepress preview .",
"prebuild": "npm run test:studio",
"build": "vitepress build ."
},
"devDependencies": {
+6
View File
@@ -49,6 +49,12 @@ Callback Provider 是一个通用的自定义回调接口,允许您将 DDNS
| GET | token 为空 | 使用 URL 查询参数 |
| POST | token 非空 | 使用 JSON 请求体 |
### HTTP 响应与缓存
Callback 仅将最终 HTTP 状态为 200–299 的响应视为成功,允许响应体为空,例如 HTTP 204。
HTTP 404、429 及其他非 2xx 状态均视为失败,不会将本次 IP 写入成功缓存,后续同步仍会重试。
Callback 不解析 HTTP 200 响应体中的自定义业务错误;如需报告更新失败,请让回调接口返回适当的非 2xx HTTP 状态。
### GET 请求示例
```jsonc
+6
View File
@@ -170,6 +170,12 @@ ddns -c config.json --debug
- **Record creation failed**:检查记录格式和 TTL 值,确认权限设置
- **Rate limit exceeded**:API 调用频率超限,降低请求频率
### 错误处理与缓存
Cloudflare 请求只有在 HTTP 状态为 2xx 且 API 返回 `success: true` 时才视为成功。
最终收到 HTTP 429 等错误状态,或 API 返回 `success: false`、无效响应时,本次域名更新会失败,不会将待更新的 IP 写入成功缓存,后续同步仍会重试。
区域或记录查询失败会终止当前域名的处理,不会被当作“记录不存在”而创建新记录;成功查询返回空记录列表时,仍会正常进入创建流程。
## 支持与资源
- [Cloudflare 开发者文档](https://developers.cloudflare.com/)
+320
View File
@@ -0,0 +1,320 @@
// npm --prefix docs run test:studio
// Use the existing Vite/Vue toolchain, not Node's newer TypeScript stripping API.
// Exercise setup, template events and disabled gates with real Vue reactivity;
// browser storage, file selection, clipboard and download are in-memory doubles.
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { afterEach, test } from 'node:test'
import { transformWithOxc } from 'vite'
import * as Vue from 'vue'
const sfc = await readFile(new URL('../.vitepress/theme/components/ConfigStudio.vue', import.meta.url), 'utf8')
const model = JSON.parse(await readFile(new URL('../../ddns/config/field-model.json', import.meta.url), 'utf8'))
const setup = sfc.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const { code } = await transformWithOxc(setup, 'ConfigStudio.ts')
const script = code.replace(/^import[\s\S]*?;\s*/gm, '')
const tags = [...sfc.split('</script>')[1].matchAll(/<(?:button|input|textarea|select)\b(?:[^>"']|"[^"]*"|'[^']*')*>/g)]
.map((match) => match[0])
const attr = (tag, name) => tag.match(new RegExp(`\\s${name}="([\\s\\S]*?)"`))?.[1]
const plain = (value) => JSON.parse(JSON.stringify(value))
const scopes = []
afterEach(() => scopes.splice(0).forEach((scope) => scope.stop()))
async function studio(storage = new Map()) {
const mounted = [], timers = new Map(), listeners = new Map(), clipboard = [], downloads = [], blobs = new Map()
let timerId = 0
class MemoryURL extends URL {
static createObjectURL(blob) { const key = `blob:${++timerId}`; blobs.set(key, blob); return key }
static revokeObjectURL(key) { blobs.delete(key) }
}
const context = vm.createContext({
...Vue, __DDNS_FIELD_MODEL__: model,
onMounted: (fn) => mounted.push(fn), onBeforeUnmount: () => {},
useData: () => ({ lang: Vue.ref('en') }), useRouter: () => ({}), withBase: (value) => value,
readHistoryPosition: () => null, URL: MemoryURL, Blob,
setTimeout: (fn, delay) => { const id = ++timerId; timers.set(id, { fn, delay }); return id },
clearTimeout: (id) => timers.delete(id),
window: {
location: new URL('https://docs.example.com/en/config/studio'), history: { state: null },
matchMedia: () => ({ matches: true }), confirm: () => true,
addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name),
sessionStorage: {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key),
},
},
document: {
body: { classList: { toggle() {}, remove() {} } },
getElementById: () => ({ focus() {} }),
createElement: (name) => {
assert.equal(name, 'a')
return { click() { downloads.push(blobs.get(this.href)) } }
},
},
navigator: { clipboard: { async writeText(text) { clipboard.push(text) } } },
})
const scope = Vue.effectScope()
scopes.push(scope)
scope.run(() => vm.runInContext(`${script}
globalThis.bindings = {
selectedProvider, globalState, providers, activeSection, inspectorTab,
sourceAdvancedOpen, runtimeAdvancedOpen, validationInput, validatorTouched,
hasUnsavedChanges, validationCanApply, validationErrors, canExport, previewErrors,
generatedJson, importFile, applyToBuilder, copyConfiguration, downloadConfiguration,
selectInspectorTab, toggleProviderPicker, chooseProvider, selectSection,
resetBuilder, duplicateProvider, addProvider,
...(typeof validationFromFile === 'undefined' ? {} : { validationFromFile })
};
globalThis.draftKey = DRAFT_STORAGE_KEY;
globalThis.saveDelay = DRAFT_SAVE_DELAY;
`, context))
context.ui = Vue.proxyRefs(context.bindings)
const evaluate = (expression, locals = {}) => {
context.locals = locals
return vm.runInContext(`with (ui) { with (locals) { ${expression} } }`, context)
}
const settle = async () => { await Vue.nextTick(); await Vue.nextTick() }
for (const fn of mounted) await fn()
await settle()
const api = {
storage,
get: (name) => evaluate(name),
json: () => JSON.parse(evaluate('generatedJson')),
draft: () => storage.has(context.draftKey) ? JSON.parse(storage.get(context.draftKey)) : null,
async click(handler, locals = {}) {
const tag = tags.find((tag) => tag.startsWith('<button') && attr(tag, '@click') === handler)
assert(tag, `Missing template button: ${handler}`)
const disabled = attr(tag, ':disabled')
assert(!disabled || !evaluate(disabled, locals), `Button is disabled: ${handler}`)
await evaluate(/^\w+$/.test(handler) ? `${handler}()` : handler, locals)
await settle()
},
async input(name, value) {
const tag = tags.find((tag) => attr(tag, 'v-model') === name)
assert(tag, `Missing template input: ${name}`)
evaluate(`${name} = $event.target.value; ${attr(tag, '@input') || ''}`, { $event: { target: { value } } })
await settle()
},
async import(value) {
assert(tags.some((tag) => attr(tag, '@change') === 'importFile'))
await evaluate('importFile($event)', {
$event: { target: { value: 'config.json', files: [{ text: async () => JSON.stringify(value) }] } },
})
await settle()
assert.deepEqual(plain(evaluate('validationErrors')), [])
assert.equal(evaluate('validationCanApply'), true)
},
async apply() { await api.click('applyToBuilder') },
async persist() {
await settle()
for (const [id, timer] of timers) {
if (timer.delay !== context.saveDelay) continue
timers.delete(id)
timer.fn()
}
await settle()
},
async refresh() {
const event = { warned: false, preventDefault() { this.warned = true } }
listeners.get('beforeunload')(event)
scope.stop()
return { warned: event.warned, page: await studio(storage) }
},
async copy() { await api.click('copyConfiguration'); return JSON.parse(clipboard.at(-1)) },
async download() {
await api.click('downloadConfiguration')
return JSON.parse(await downloads.at(-1).text())
},
}
return api
}
const base = {
$schema: model.schema.url, ssl: true, cache: false, log: { level: 'INFO' },
providers: [{
provider: 'cloudflare', id: null, token: 'offline-placeholder',
ipv4: ['home.example.com'], ipv6: [], index4: ['public', 'default'], index6: false, ttl: 300,
}],
}
async function importApply(value) {
const page = await studio()
await page.import(value)
await page.apply()
assert.equal(page.get('canExport'), true)
return page
}
async function createUnsaved() {
const page = await studio()
await page.click('toggleProviderPicker')
await page.click('chooseProvider(provider.value)', { provider: { value: 'cloudflare' } })
await page.input('selectedProvider.token', 'offline-page-token')
await page.click('selectSection(section.key)', { section: { key: 'records' } })
await page.input('selectedProvider.ipv4Text', 'unsaved.example.com')
await page.click('sourceAdvancedOpen = !sourceAdvancedOpen')
await page.input('selectedProvider.ttl', '600')
await page.persist()
return page
}
test('page-created, unexported JSON edits survive Apply and refresh', async () => {
const page = await createUnsaved()
await page.click("selectInspectorTab('validate')")
const edited = page.json()
edited.providers[0].ttl = 900
await page.input('validationInput', JSON.stringify(edited))
await page.apply()
assert.equal(page.get('hasUnsavedChanges'), true)
await page.persist()
assert(page.draft())
const { page: restored, warned } = await page.refresh()
assert.equal(warned, true)
assert.deepEqual(restored.json(), edited)
assert.equal(restored.get('validatorTouched'), false)
await restored.copy()
assert.equal(restored.get('hasUnsavedChanges'), false)
assert.equal(restored.draft(), null)
})
test('ordinary unsaved refresh and successful export keep their existing behavior', async () => {
const page = await createUnsaved()
const original = page.json()
const { page: restored, warned } = await page.refresh()
assert.equal(warned, true)
assert.deepEqual(restored.json(), original)
assert.deepEqual(await restored.copy(), original)
assert.deepEqual(await restored.download(), original)
assert.equal(restored.get('hasUnsavedChanges'), false)
assert.equal(restored.draft(), null)
})
test('unchanged saved-file imports stay clean, including a draft restored before Apply', async () => {
const page = await studio()
await page.import(base)
await page.persist()
const { page: restored } = await page.refresh()
await restored.apply()
assert.equal(restored.get('hasUnsavedChanges'), false)
assert.equal(restored.draft(), null)
assert.deepEqual(await restored.download(), base)
})
test('editing imported JSON invalidates its saved-file provenance', async () => {
const page = await studio()
await page.import(base)
const edited = plain(base)
edited.providers[0].ttl = 900
await page.input('validationInput', JSON.stringify(edited))
await page.apply()
assert.equal(page.get('hasUnsavedChanges'), true)
const { page: restored } = await page.refresh()
assert.equal(restored.json().providers[0].ttl, 900)
})
test('JSON regenerated from an edited builder is not treated as the imported file', async () => {
const page = await importApply(base)
await page.click('selectSection(section.key)', { section: { key: 'records' } })
await page.input('selectedProvider.ipv4Text', 'changed.example.com')
await page.click("selectInspectorTab('validate')")
await page.apply()
assert.equal(page.get('hasUnsavedChanges'), true)
const { page: restored } = await page.refresh()
assert.deepEqual(restored.json().providers[0].ipv4, ['changed.example.com'])
})
test('old v2 drafts without import provenance are retained conservatively', async () => {
const page = await studio()
await page.import(base)
await page.persist()
const draft = page.draft()
delete draft.validationFromFile
const storage = new Map(page.storage)
storage.set([...storage.keys()][0], JSON.stringify(draft))
const restored = await studio(storage)
await restored.apply()
assert.equal(restored.get('hasUnsavedChanges'), true)
})
test('v1 drafts retain explicit empty overrides and unapplied validation text', async () => {
const config = { ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] }
const validationInput = '{ "unfinished":'
const restored = await studio(new Map([[
'ddns-config-studio-draft-v1',
JSON.stringify({ version: 1, config, validationInput }),
]]))
assert.deepEqual(restored.json().providers[0].extra, {})
assert.equal(restored.get('validationInput'), validationInput)
assert.equal(restored.get('validatorTouched'), true)
assert.equal(restored.get('hasUnsavedChanges'), true)
})
const extraCases = [
['flat global plus provider extra', { proxied: true }, { extra: { comment: 'home' } }, { comment: 'home', proxied: true }],
['nested global plus flat provider field', { extra: { proxied: true } }, { comment: 'home' }, { proxied: true, comment: 'home' }],
['explicit empty provider override', { extra: { proxied: true } }, { extra: {} }, {}],
['nonempty provider override', { extra: { proxied: true } }, { extra: { proxied: false } }, { proxied: false }],
['flat aliases override nested fields', { extra_proxied: true }, { extra: { proxied: false } }, { proxied: true }],
['flat alias order survives cross-scope overrides', { proxied: true, extra_proxied: false }, { proxied: true }, { proxied: false }],
['nested custom objects flatten before inheritance', { settings: { first: 1, second: 2 } }, { settings: { first: 3 } }, { settings_first: 3, settings_second: 2 }],
]
for (const [name, global, provider, expected] of extraCases) {
test(`extra round trip: ${name}`, async () => {
const page = await importApply({ ...base, ...global, providers: [{ ...base.providers[0], ...provider }] })
assert.deepEqual(JSON.parse(page.get('selectedProvider.extraText')), expected)
assert.deepEqual((await page.copy()).providers[0].extra, expected)
assert.deepEqual((await page.download()).providers[0].extra, expected)
// Change an unrelated visible field so the normal draft lifecycle saves this state.
await page.click('selectSection(section.key)', { section: { key: 'records' } })
await page.input('selectedProvider.ipv4Text', 'draft.example.com')
const { page: restored } = await page.refresh()
assert.deepEqual(restored.json().providers[0].extra, expected)
})
}
test('providers without custom fields keep inheriting global extra', async () => {
const page = await importApply({ ...base, proxied: true })
assert.equal(page.get('selectedProvider.extraText'), '')
assert(!('extra' in page.json().providers[0]))
assert.deepEqual(page.json().extra, { proxied: true })
})
test('empty extra text inherits, while an explicit object remains an override', async () => {
const page = await importApply({ ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] })
await page.click('selectSection(section.key)', { section: { key: 'runtime' } })
await page.input('selectedProvider.extraText', '')
assert(!('extra' in page.json().providers[0]))
await page.input('selectedProvider.extraText', '{}')
assert.deepEqual(page.json().providers[0].extra, {})
await page.input('globalState.extraText', '{}')
assert.deepEqual(page.json().extra, {})
})
test('new, duplicated and changed providers do not retain previous custom overrides', async () => {
const page = await importApply({ ...base, extra: { proxied: true }, providers: [{ ...base.providers[0], extra: {} }] })
await page.click('duplicateProvider')
assert.equal(page.get('selectedProvider.extraText'), '')
assert(!('extra' in page.json().providers[1]))
assert(!('token' in page.json().providers[1]))
await page.click('addProvider')
assert(!('extra' in page.json().providers[2]))
await page.click('selectSection(section.key)', { section: { key: 'runtime' } })
await page.input('selectedProvider.extraText', '{"comment":"discard on provider change"}')
await page.click('selectSection(section.key)', { section: { key: 'provider' } })
await page.click('toggleProviderPicker')
await page.click('chooseProvider(provider.value)', { provider: { value: 'cloudflare' } })
assert.equal(page.get('selectedProvider.extraText'), '')
await page.click('resetBuilder')
assert.equal(page.get('hasUnsavedChanges'), false)
assert.equal(page.get('selectedProvider.extraText'), '')
})
test('legacy single-provider imports retain extra values and absence', async () => {
const legacy = { dns: 'cloudflare', token: 'offline-placeholder', ipv4: ['home.example.com'] }
const inherited = await importApply(legacy)
assert(!('extra' in inherited.json().providers[0]))
const explicit = await importApply({ ...legacy, extra: {} })
assert.deepEqual(explicit.json().providers[0].extra, {})
const fields = await importApply({ ...legacy, extra: { proxied: false }, extra_proxied: true })
assert.deepEqual(fields.json().providers[0].extra, { proxied: true })
})
+111 -2
View File
@@ -163,7 +163,13 @@ class _FixtureHandler(BaseHTTPRequestHandler):
self._send(200, "current address: {}\n".format(TEST_IPV4))
elif path == "/ip/v6":
self._send(200, "{}\n".format(TEST_IPV6))
elif path == "/callback/fail":
elif path == "/ip/error-address":
self._send(403, "upstream 198.51.100.20 unavailable")
elif path == "/ip/mixed-v6":
self._send(200, "2001:db8::192.0.2.10")
elif path == "/ip/compact-v6":
self._send(200, "IP:{}".format(TEST_IPV6))
elif path in ("/callback/fail", "/callback/fail.example.com"):
self._send(400, "callback rejected")
elif path.startswith("/callback/"):
self._send(200, "callback accepted")
@@ -173,7 +179,7 @@ class _FixtureHandler(BaseHTTPRequestHandler):
def do_POST(self):
body = self._read_body()
path = self._record(body)
if path == "/callback/fail":
if path in ("/callback/fail", "/callback/fail.example.com"):
self._send(400, "callback rejected")
elif path.startswith("/callback/"):
self._send(200, "callback accepted")
@@ -408,6 +414,92 @@ class OfflineE2ETestCase(unittest.TestCase):
class TestCliE2E(OfflineE2ETestCase):
"""Exercise complete DDNS update flows through public process entrypoints."""
def test_ipv6_runs_when_ipv4_is_disabled_or_fails(self):
"""Update the working family independently of a disabled or unavailable IPv4 source."""
for name, index4, expected_exit in (
("disabled", False, 0),
("unavailable", ["url:" + self.fixture_url + "/ip/invalid"], 1),
):
self.fixture_state.reset()
config = self._callback_config(
"/callback/dual-failure?type=__RECORDTYPE__&ip=__IP__", ["v4.example.com"], ["v6.example.com"]
)
config["index4"] = index4
config_path = self._write_config(name + ".json", config)
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, expected_exit, result.stderr)
requests = self.fixture_state.requests_for("/callback/dual-failure")
self.assertEqual(len(requests), 1, result.stderr)
self.assertEqual(requests[0]["query"], {"type": ["AAAA"], "ip": [TEST_IPV6]})
self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1)
def test_partial_domain_failure_preserves_only_successful_cache(self):
"""Report partial failure and retry the rejected domain, while retaining successful cached records."""
config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True)
config_path = self._write_config("partial-domains.json", config)
for attempt in (1, 2):
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), attempt)
def test_http_error_address_is_ignored(self):
"""Do not update DNS from the address contained in an IP endpoint error page."""
config = self._callback_config("/callback/error-address?ip=__IP__", ["ip.example.com"])
config["index4"] = ["url:" + self.fixture_url + "/ip/error-address", "url:" + self.fixture_url + "/ip/v4"]
config_path = self._write_config("error-address.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/error-address")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV4])
self.assertEqual(len(self.fixture_state.requests_for("/ip/error-address")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1)
def test_mixed_ipv6_address_is_preserved(self):
"""Forward a complete mixed-notation IPv6 address to the callback."""
config = self._callback_config("/callback/mixed-v6?ip=__IP__", ipv6_domains=["mixed.example.com"])
config["index6"] = ["url:" + self.fixture_url + "/ip/mixed-v6"]
config_path = self._write_config("mixed-v6.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/mixed-v6")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], ["2001:db8::192.0.2.10"])
def test_compact_ipv6_label_is_preserved(self):
"""Resolve a compact text response over real HTTP and forward its IPv6 address."""
config = self._callback_config("/callback/compact-v6?ip=__IP__", ipv6_domains=["label.example.com"])
config["index6"] = ["url:" + self.fixture_url + "/ip/compact-v6"]
config_path = self._write_config("compact-v6.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/compact-v6")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV6])
self.assertEqual(len(self.fixture_state.requests_for("/ip/compact-v6")), 1)
def test_callback_http_failure_is_not_cached(self):
"""Retry a missing callback endpoint instead of caching its error body as a successful update."""
config = self._callback_config("/missing", ["callback.example.com"], cache=True)
config_path = self._write_config("missing-callback.json", config)
for attempt in (1, 2):
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/missing")), attempt)
def test_cli_only_dual_stack_and_rule_fallback(self):
"""Resolve both address families and fall back after an unusable rule."""
result = self._run(
@@ -706,6 +798,23 @@ class TestMcpE2E(OfflineE2ETestCase):
)
return result, [json.loads(line) for line in lines]
def test_partial_domain_failure_is_a_tool_error(self):
"""Report a rejected domain through MCP and retry it without repeating successful writes."""
config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True)
config_path = self._write_config("partial-mcp.json", config)
requests = [
self._modern_request(request_id, "tools/call", {"name": "update_dns_records", "arguments": {}})
for request_id in (1, 2)
]
_, responses = self._run_mcp(config_path, requests)
for response in responses:
self.assertTrue(response["result"]["isError"])
self.assertIn("Synchronization failed", response["result"]["content"][0]["text"])
self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), 2)
def test_modern_discovery_tools_update_and_cached_status(self):
"""Discover tools, synchronize dual-stack records, and read cached status."""
token = json.dumps(
+88 -42
View File
@@ -3,7 +3,7 @@
Tests for ddns.ip module including integration tests
"""
from __init__ import unittest, patch, MagicMock
from __init__ import unittest, patch
from ddns import ip
from ddns.__main__ import get_ip
from ddns.util.http import HttpResponse
@@ -24,9 +24,7 @@ class TestIpModule(unittest.TestCase):
def test_url_v4_success(self, mock_request):
"""测试自定义URL获取IPv4 - 成功"""
# 模拟成功响应
mock_response = MagicMock()
mock_response.body = "1.2.3.4"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4")
result = ip.public_v4("https://test.example.com/ip")
@@ -37,9 +35,7 @@ class TestIpModule(unittest.TestCase):
def test_url_v6_success(self, mock_request):
"""测试自定义URL获取IPv6 - 成功"""
# 模拟成功响应
mock_response = MagicMock()
mock_response.body = "2001:db8::1"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "2001:db8::1")
result = ip.public_v6("https://test.example.com/ipv6")
@@ -61,9 +57,7 @@ class TestIpModule(unittest.TestCase):
def test_url_v4_invalid_response(self, mock_request):
"""测试自定义URL获取IPv4 - 无效响应"""
# 模拟无效响应
mock_response = MagicMock()
mock_response.body = "invalid response"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "invalid response")
result = ip.public_v4("https://test.example.com/ip")
@@ -74,9 +68,7 @@ class TestIpModule(unittest.TestCase):
def test_public_v4_multiple_apis_first_success(self, mock_request):
"""测试公网IPv4获取 - 多个API第一个成功"""
# 模拟第一个API成功
mock_response = MagicMock()
mock_response.body = "1.2.3.4"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4")
result = ip.public_v4()
@@ -91,10 +83,7 @@ class TestIpModule(unittest.TestCase):
def mock_request_side_effect(method, url, **kwargs):
if url == ip.PUBLIC_IPV4_APIS[0]:
raise Exception("First API failed")
else:
mock_response = MagicMock()
mock_response.body = "1.2.3.4"
return mock_response
return HttpResponse(200, "OK", {}, "1.2.3.4")
mock_request.side_effect = mock_request_side_effect
@@ -122,9 +111,7 @@ class TestIpModule(unittest.TestCase):
def test_public_v6_multiple_apis_first_success(self, mock_request):
"""测试公网IPv6获取 - 多个API第一个成功"""
# 模拟第一个API成功
mock_response = MagicMock()
mock_response.body = "2001:db8::1"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "2001:db8::1")
result = ip.public_v6()
@@ -139,10 +126,7 @@ class TestIpModule(unittest.TestCase):
def mock_request_side_effect(method, url, **kwargs):
if url == ip.PUBLIC_IPV6_APIS[0]:
raise Exception("First API failed")
else:
mock_response = MagicMock()
mock_response.body = "2001:db8::1"
return mock_response
return HttpResponse(200, "OK", {}, "2001:db8::1")
mock_request.side_effect = mock_request_side_effect
@@ -184,9 +168,7 @@ class TestIpModule(unittest.TestCase):
if url == ip.PUBLIC_IPV4_APIS[0]:
raise Exception("First API failed")
elif url == ip.PUBLIC_IPV4_APIS[1]:
mock_response = MagicMock()
mock_response.body = "1.2.3.4"
return mock_response
return HttpResponse(200, "OK", {}, "1.2.3.4")
else:
raise Exception("Unexpected URL")
@@ -203,9 +185,7 @@ class TestIpModule(unittest.TestCase):
def test_get_ip_url_mode_backward_compatibility(self, mock_request):
"""测试通过get_ip使用url:模式的向后兼容性"""
# 模拟成功响应
mock_response = MagicMock()
mock_response.body = "1.2.3.4"
mock_request.return_value = mock_response
mock_request.return_value = HttpResponse(200, "OK", {}, "1.2.3.4")
# 使用"url:"规则获取IPv4地址
result = get_ip("4", ["url:https://custom.api.com/ip"])
@@ -221,14 +201,13 @@ class TestIpModule(unittest.TestCase):
"""测试get_ip在多个URL规则之间回退"""
def mock_request_side_effect(method, url, **kwargs):
mock_response = MagicMock()
if url == "https://primary.example.com/ip":
mock_response.body = "invalid response"
body = "invalid response"
elif url == "https://backup.example.com/ip":
mock_response.body = "1.2.3.4"
body = "1.2.3.4"
else:
raise Exception("Unexpected URL")
return mock_response
return HttpResponse(200, "OK", {}, body)
mock_request.side_effect = mock_request_side_effect
@@ -242,14 +221,13 @@ class TestIpModule(unittest.TestCase):
"""测试get_ip在规则返回空结果时继续尝试下一条"""
def mock_request_side_effect(method, url, **kwargs):
mock_response = MagicMock()
if url in ip.PUBLIC_IPV4_APIS:
mock_response.body = "invalid response"
body = "invalid response"
elif url == "https://backup.api.com/ip":
mock_response.body = "1.2.3.4"
body = "1.2.3.4"
else:
raise Exception("Unexpected URL")
return mock_response
return HttpResponse(200, "OK", {}, body)
mock_request.side_effect = mock_request_side_effect
@@ -369,14 +347,13 @@ class TestIpModule(unittest.TestCase):
"""测试IPv6规则链在空结果时继续回退"""
def mock_request_side_effect(method, url, **kwargs):
mock_response = MagicMock()
if url == "https://primary.example.com/ipv6":
mock_response.body = "invalid response"
body = "invalid response"
elif url == "https://backup.example.com/ipv6":
mock_response.body = "2001:db8::1"
body = "2001:db8::1"
else:
raise Exception("Unexpected URL")
return mock_response
return HttpResponse(200, "OK", {}, body)
mock_request.side_effect = mock_request_side_effect
@@ -385,6 +362,75 @@ class TestIpModule(unittest.TestCase):
self.assertEqual(result, "2001:db8::1")
self.assertEqual(mock_request.call_count, 2)
@patch("ddns.ip.request")
def test_error_response_address_is_not_used(self, mock_request):
"""Do not treat an address in an HTTP failure body as a discovery result."""
for status in (302, 403, 404, 429, 500, 503):
mock_request.return_value = HttpResponse(status, "request failed", {}, "upstream 198.51.100.20 failed")
self.assertIsNone(ip.public_v4("https://test.example.com/ip"), str(status))
@patch("ddns.ip.request")
def test_public_apis_fall_back_after_http_error(self, mock_request):
"""HTTP errors must advance to the next IPv4 or IPv6 discovery endpoint."""
for public, failed_address, expected_address in (
(ip.public_v4, "198.51.100.20", "192.0.2.10"),
(ip.public_v6, "2001:db8::bad", "2001:db8::10"),
):
mock_request.reset_mock()
mock_request.side_effect = [
HttpResponse(503, "Unavailable", {}, "upstream {} unavailable".format(failed_address)),
HttpResponse(200, "OK", {}, expected_address),
]
self.assertEqual(public(), expected_address)
self.assertEqual(mock_request.call_count, 2)
@patch("ddns.ip.request")
def test_ipv6_literals_are_not_truncated(self, mock_request):
"""Keep compressed, uncompressed, and embedded-IPv4 literals complete."""
for address in (
"::",
"::1",
"2001:db8::1",
"2001:db8:0:1:2:3:4:5",
"2001:db8::192.0.2.10",
"::ffff:192.0.2.10",
"::192.0.2.10",
"2001:db8:0:1:2:3:192.0.2.10",
"2001:db8:0:1::192.0.2.10",
"2001:db8:0:1:2::192.0.2.10",
"2001:db8::1:2:192.0.2.10",
"2001:DB8::192.0.2.10",
):
mock_request.return_value = HttpResponse(200, "OK", {}, "Address: {}.".format(address))
self.assertEqual(ip.public_v6("https://test.example.com/ip"), address)
@patch("ddns.ip.request")
def test_ipv6_compact_labels_preserve_complete_addresses(self, mock_request):
"""A text-label colon is a separator, not part of the IPv6 literal."""
for label in ("IP:", "address:"):
for address in ("2001:db8::1", "::1", "::ffff:192.0.2.10", "2001:db8::192.0.2.10"):
mock_request.return_value = HttpResponse(200, "OK", {}, label + address)
self.assertEqual(ip.public_v6("https://test.example.com/ip"), address, label + address)
@patch("ddns.ip.request")
def test_invalid_ipv6_literal_does_not_yield_a_valid_fragment(self, mock_request):
"""A malformed address must not be replaced with one of its valid-looking fragments."""
for address in (
"2001:db8::192.0.2.999",
"2001:db8:0:0:0:0:0:0:1",
"2001:db8:::1",
":2001:db8::1",
"2001:db8::1:",
":::1",
):
mock_request.return_value = HttpResponse(200, "OK", {}, address)
self.assertIsNone(ip.public_v6("https://test.example.com/ip"), address)
if __name__ == "__main__":
unittest.main()
+139
View File
@@ -31,6 +31,145 @@ class TestMain(unittest.TestCase):
mock_cache_new.assert_called_once_with(True, config.md5(), __main__.logger, 86400)
self.assertEqual(mock_update_ip.call_count, 2)
@patch.object(__main__, "get_ip", side_effect=[None, "2001:db8::1"])
@patch.object(__main__.Cache, "new", return_value=None)
@patch.object(__main__, "get_provider_class")
def test_run_attempts_ipv6_after_ipv4_failure(self, mock_provider_class, mock_cache_new, mock_get_ip):
"""An unavailable IPv4 address must not prevent an IPv6 update."""
provider = MagicMock()
provider.set_record.return_value = True
mock_provider_class.return_value = lambda *args, **kwargs: provider
config = Config(
cli_config={
"dns": "debug",
"ipv4": ["v4.example.com"],
"ipv6": ["v6.example.com"],
"index4": ["default"],
"index6": ["default"],
}
)
self.assertFalse(__main__.run(config))
self.assertEqual([call[0][0] for call in mock_get_ip.call_args_list], ["4", "6"])
provider.set_record.assert_called_once()
self.assertEqual(provider.set_record.call_args[0], ("v6.example.com", "2001:db8::1"))
self.assertEqual(provider.set_record.call_args[1]["record_type"], "AAAA")
@patch.object(__main__, "_get_ip_from_rule", return_value="2001:db8::1")
@patch.object(__main__.Cache, "new", return_value=None)
@patch.object(__main__, "get_provider_class")
def test_run_disabled_ipv4_does_not_block_ipv6(self, mock_provider_class, mock_cache_new, mock_get_rule):
"""Keeping disabled IPv4 domains must not disable the configured IPv6 family."""
provider = MagicMock()
provider.set_record.return_value = True
mock_provider_class.return_value = lambda *args, **kwargs: provider
config = Config(
cli_config={
"dns": "debug",
"ipv4": ["v4.example.com"],
"ipv6": ["v6.example.com"],
"index4": False,
"index6": ["default"],
}
)
self.assertTrue(__main__.run(config))
mock_get_rule.assert_called_once_with("6", "default")
provider.set_record.assert_called_once()
self.assertEqual(provider.set_record.call_args[1]["record_type"], "AAAA")
@patch.object(__main__, "get_ip", return_value=False)
def test_update_ip_skips_disabled_family(self, mock_get_ip):
"""An explicitly disabled family is skipped without discovery or writes."""
provider = MagicMock()
result = __main__.update_ip(
provider, None, False, ["disabled.example.com"], "AAAA", Config(cli_config={"dns": "debug"})
)
self.assertIsNone(result)
mock_get_ip.assert_not_called()
provider.set_record.assert_not_called()
@patch.object(__main__, "get_ip", return_value="192.0.2.1")
def test_update_ip_reports_partial_failures_and_continues(self, mock_get_ip):
"""Later success must not hide an earlier failure, or stop subsequent domains."""
config = Config(cli_config={"dns": "debug"})
for outcomes in (
[True, False, True],
[False, True],
[True, RuntimeError("update rejected"), True],
[RuntimeError("update rejected"), True],
[None, True],
):
provider = MagicMock()
provider.set_record.side_effect = outcomes
domains = ["record{}.example.com".format(index) for index in range(len(outcomes))]
cache = {}
result = __main__.update_ip(provider, cache, ["default"], domains, "A", config)
self.assertFalse(result, repr(outcomes))
self.assertEqual(provider.set_record.call_count, len(domains))
self.assertEqual(
cache,
{"{}:A".format(domain): "192.0.2.1" for domain, outcome in zip(domains, outcomes) if outcome is True},
)
@patch.object(__main__, "get_ip", return_value="192.0.2.1")
def test_cached_success_does_not_mask_failed_update(self, mock_get_ip):
"""A cached record remains valid without making a different failed record successful."""
provider = MagicMock()
provider.set_record.return_value = False
cache = {"cached.example.com:A": "192.0.2.1"}
result = __main__.update_ip(
provider,
cache,
["default"],
["cached.example.com", "failed.example.com"],
"A",
Config(cli_config={"dns": "debug"}),
)
self.assertFalse(result)
provider.set_record.assert_called_once()
self.assertEqual(cache, {"cached.example.com:A": "192.0.2.1"})
@patch.object(__main__, "get_ip", return_value="192.0.2.1")
def test_update_ip_all_successes_populate_cache(self, mock_get_ip):
"""All successful records retain the existing cache behavior."""
provider = MagicMock()
provider.set_record.return_value = True
cache = {}
domains = ["first.example.com", "second.example.com"]
self.assertTrue(
__main__.update_ip(provider, cache, ["default"], domains, "A", Config(cli_config={"dns": "debug"}))
)
self.assertEqual(provider.set_record.call_count, 2)
self.assertEqual(cache, {domain + ":A": "192.0.2.1" for domain in domains})
@patch.object(__main__, "get_ip", return_value="192.0.2.1")
@patch.object(__main__.Cache, "new", return_value=None)
@patch.object(__main__, "get_provider_class")
def test_main_reports_partial_domain_failure(self, mock_provider_class, mock_cache_new, mock_get_ip):
"""The process result must report any rejected domain, not just total failures."""
provider = MagicMock()
provider.set_record.side_effect = [True, False]
mock_provider_class.return_value = lambda *args, **kwargs: provider
config = Config(cli_config={"dns": "debug", "ipv4": ["first.example.com", "second.example.com"]})
with patch.object(__main__, "load_configs", return_value=[config]):
with patch.object(sys, "stdout", io.StringIO()):
with self.assertRaises(SystemExit) as context:
__main__.main()
self.assertEqual(context.exception.code, 1)
self.assertEqual(provider.set_record.call_count, 2)
@patch.object(__main__, "get_ip", return_value="192.0.2.1")
def test_update_ip_stops_before_next_domain_when_cancelled(self, mock_get_ip):
"""Stop cooperative updates between configured DNS records."""
+42 -1
View File
@@ -5,8 +5,9 @@ Unit tests for SimpleProvider
@author: GitHub Copilot
"""
from base_test import BaseProviderTestCase, unittest, MagicMock
from base_test import BaseProviderTestCase, unittest, MagicMock, patch
from ddns.provider._base import SimpleProvider, TYPE_FORM, encode_params
from ddns.util.http import HttpResponse
class _TestableSimpleProvider(SimpleProvider):
@@ -222,6 +223,46 @@ class _TestableSimpleProviderClass(BaseProviderTestCase):
self.assertTrue(result)
class TestSimpleProviderHTTPStatus(BaseProviderTestCase):
"""A provider may opt in to strict HTTP status checks without changing the default."""
def setUp(self):
super(TestSimpleProviderHTTPStatus, self).setUp()
self.provider = _TestableSimpleProvider(self.id, self.token)
self.mock_logger(self.provider)
def test_default_preserves_non_fatal_status_responses(self):
for status in (199, 300, 304, 404, 408, 409, 422, 429):
response = HttpResponse(status, "Test response", {}, '{"error": "Provider-specific result"}')
with patch("ddns.provider._base.request", return_value=response):
self.assertEqual(self.provider._http("GET", "/test"), {"error": "Provider-specific result"})
def test_default_keeps_existing_fatal_errors(self):
for status in (400, 401, 403, 500, 502, 503):
response = HttpResponse(status, "Test response", {}, '{"error": "Failure"}')
with patch("ddns.provider._base.request", return_value=response):
with self.assertRaises(RuntimeError) as context:
self.provider._http("GET", "/test")
self.assertIn(str(status), str(context.exception))
def test_strict_status_rejects_non_2xx_responses(self):
self.provider._require_http_success = True
for status in (199, 300, 304, 400, 401, 403, 404, 408, 409, 422, 429, 500):
response = HttpResponse(status, "Test response", {}, '{"error": "Failure"}')
with patch("ddns.provider._base.request", return_value=response):
with self.assertRaises(RuntimeError) as context:
self.provider._http("GET", "/test")
self.assertIn(str(status), str(context.exception))
def test_strict_status_accepts_2xx_and_empty_bodies(self):
self.provider._require_http_success = True
self.provider.decode_response = False
for status in (200, 201, 204, 299):
response = HttpResponse(status, "Success", {}, "")
with patch("ddns.provider._base.request", return_value=response):
self.assertEqual(self.provider._http("GET", "/test"), "")
class _TestableSimpleProviderWithNoAPI(SimpleProvider):
"""Test implementation without API defined"""
+115
View File
@@ -7,12 +7,19 @@ Unit tests for CallbackProvider
import os
import sys
import json
import logging
import random
import platform
import shutil
import tempfile
from time import sleep
from base_test import BaseProviderTestCase, is_network_error, patch, unittest
from ddns.__main__ import update_ip
from ddns.cache import Cache
from ddns.config import Config
from ddns.provider.callback import CallbackProvider
from ddns.util.http import HttpResponse
class TestCallbackProvider(BaseProviderTestCase):
@@ -272,6 +279,114 @@ class TestCallbackProvider(BaseProviderTestCase):
provider.set_record("example.com", "192.168.1.1")
class TestCallbackProviderHTTPResponses(BaseProviderTestCase):
"""Use real response handling and isolated on-disk caches without network requests."""
def setUp(self):
super(TestCallbackProviderHTTPResponses, self).setUp()
self.temp_dir = tempfile.mkdtemp(prefix="ddns-callback-test-")
self.addCleanup(shutil.rmtree, self.temp_dir)
self.cache_path = os.path.join(self.temp_dir, "records.cache")
# Keep file timestamps aligned with the clock, including on coarse Windows clocks.
self.cache_timestamp = 1700000000
cache_clock = patch("ddns.cache.time", return_value=self.cache_timestamp)
cache_clock.start()
self.addCleanup(cache_clock.stop)
self.domain = "www.example.com"
self.address = "192.0.2.9"
self.url = "https://example.invalid/update?domain=__DOMAIN__&ip=__IP__"
self.post_body = '{"domain": "__DOMAIN__", "value": "__IP__"}'
def _sync_once(self, status, body, token):
config = Config(
json_config={
"dns": "callback",
"id": self.url,
"token": token,
"ipv4": [self.domain],
"cache": self.cache_path,
}
)
provider = CallbackProvider(config.id, config.token)
self.mock_logger(provider)
cache = Cache.new(config.cache, config.md5(), provider.logger, config.cache_max_age)
try:
response = HttpResponse(status, "Test response", {}, body)
with patch("ddns.__main__.get_ip", return_value=self.address), patch(
"ddns.provider._base.request", return_value=response
) as mock_request:
result = update_ip(provider, cache, ["public"], [self.domain], "A", config)
return result, list(mock_request.call_args_list)
finally:
cache.close()
os.utime(self.cache_path, (self.cache_timestamp, self.cache_timestamp))
def _read_cache(self):
with open(self.cache_path, "r") as cache_file:
return json.load(cache_file)
def _assert_failure_retries(self, status, token):
result, calls = self._sync_once(status, "Nonempty error response", token)
self.assertFalse(result)
self.assertEqual([call[0][0] for call in calls], ["POST" if token else "GET"])
self.assertEqual(self._read_cache(), {})
result, calls = self._sync_once(204, "", token)
self.assertTrue(result)
self.assertEqual(len(calls), 1)
self.assertEqual(self._read_cache(), {self.domain + ":A": self.address})
result, calls = self._sync_once(429, "Must not be requested after success", token)
self.assertTrue(result)
self.assertEqual(calls, [])
def test_get_404_is_not_cached(self):
self._assert_failure_retries(404, "")
def test_post_404_is_not_cached(self):
self._assert_failure_retries(404, self.post_body)
def test_get_429_is_not_cached(self):
self._assert_failure_retries(429, "")
def test_post_429_is_not_cached(self):
self._assert_failure_retries(429, self.post_body)
def test_get_400_is_not_cached(self):
self._assert_failure_retries(400, "")
def test_post_400_is_not_cached(self):
self._assert_failure_retries(400, self.post_body)
def test_empty_2xx_response_is_success(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
for status in (200, 201, 204, 299):
with patch(
"ddns.provider._base.request", return_value=HttpResponse(status, "Success", {}, "")
) as mock_request:
self.assertTrue(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
def test_other_non_2xx_responses_fail(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
for status in (199, 300, 302, 304, 403, 409, 500):
with patch(
"ddns.provider._base.request", return_value=HttpResponse(status, "Failure", {}, "Error body")
) as mock_request:
self.assertFalse(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
def test_http_200_business_error_body_is_not_interpreted(self):
provider = CallbackProvider(self.url, "")
self.mock_logger(provider)
body = '{"success": false, "error": "Custom application response"}'
with patch("ddns.provider._base.request", return_value=HttpResponse(200, "OK", {}, body)) as mock_request:
self.assertTrue(provider.set_record(self.domain, self.address))
mock_request.assert_called_once()
class TestCallbackProviderRealIntegration(BaseProviderTestCase):
"""Real integration tests for CallbackProvider using httpbin.org"""
+150 -3
View File
@@ -5,9 +5,17 @@ Unit tests for CloudflareProvider
@author: GitHub Copilot
"""
from base_test import BaseProviderTestCase, patch, unittest
import json
import os
import shutil
import tempfile
from base_test import BaseProviderTestCase, patch, unittest
from ddns.__main__ import update_ip
from ddns.cache import Cache
from ddns.config import Config
from ddns.provider.cloudflare import CloudflareProvider
from ddns.util.http import HttpResponse
class TestCloudflareProvider(BaseProviderTestCase):
@@ -103,9 +111,10 @@ class TestCloudflareProvider(BaseProviderTestCase):
with patch.object(provider, "_http") as mock_http:
mock_http.return_value = {"success": False, "errors": ["Invalid API key"]}
result = provider._request("GET", "/test")
with self.assertRaises(RuntimeError):
provider._request("GET", "/test")
self.assertEqual(result, {"success": False, "errors": ["Invalid API key"]})
mock_http.assert_called_once()
def test_request_filters_none_params(self):
"""Test _request method filters out None parameters"""
@@ -661,5 +670,143 @@ class TestCloudflareProviderIntegration(BaseProviderTestCase):
# The workflow should work the same regardless of auth method
class TestCloudflareProviderHTTPResponses(BaseProviderTestCase):
"""Exercise the real provider and disk cache with only transport/IP discovery mocked."""
def setUp(self):
super(TestCloudflareProviderHTTPResponses, self).setUp()
self.temp_dir = tempfile.mkdtemp(prefix="ddns-cloudflare-test-")
self.addCleanup(shutil.rmtree, self.temp_dir)
self.cache_path = os.path.join(self.temp_dir, "records.cache")
# Keep file timestamps aligned with the clock, including on coarse Windows clocks.
self.cache_timestamp = 1700000000
cache_clock = patch("ddns.cache.time", return_value=self.cache_timestamp)
cache_clock.start()
self.addCleanup(cache_clock.stop)
self.domain = "www.example.com"
self.address = "192.0.2.9"
self.config = Config(
json_config={"dns": "cloudflare", "token": self.token, "ipv4": [self.domain], "cache": self.cache_path}
)
self.api_error = {"success": False, "errors": [{"code": 1000, "message": "Request failed"}]}
def _response(self, payload, status=200):
return HttpResponse(status, "Test response", {}, json.dumps(payload))
def _lookup_responses(self, existing):
records = [{"id": "rec123", "name": self.domain, "type": "A", "content": "192.0.2.1"}] if existing else []
return [
self._response({"success": True, "result": [{"id": "zone123", "name": "example.com"}]}),
self._response({"success": True, "result": records}),
]
def _sync_once(self, responses, extra=None):
provider = CloudflareProvider("", self.token)
self.mock_logger(provider)
self.config.extra = extra or {}
cache = Cache.new(self.config.cache, self.config.md5(), provider.logger, self.config.cache_max_age)
try:
with patch("ddns.__main__.get_ip", return_value=self.address), patch(
"ddns.provider._base.request", side_effect=responses
) as mock_request:
result = update_ip(provider, cache, ["public"], [self.domain], "A", self.config)
return result, list(mock_request.call_args_list)
finally:
cache.close()
os.utime(self.cache_path, (self.cache_timestamp, self.cache_timestamp))
def _read_cache(self):
with open(self.cache_path, "r") as cache_file:
return json.load(cache_file)
def _assert_write_failure_retries(self, existing, status):
responses = self._lookup_responses(existing) + [self._response(self.api_error, status)]
result, calls = self._sync_once(responses)
self.assertFalse(result)
self.assertEqual([call[0][0] for call in calls], ["GET", "GET", "PUT" if existing else "POST"])
self.assertEqual(self._read_cache(), {})
# Reload the actual cache file: a failed write must not suppress the next attempt.
responses = self._lookup_responses(existing) + [self._response({"success": True, "result": {"id": "rec123"}})]
result, calls = self._sync_once(responses)
self.assertTrue(result)
self.assertEqual(len(calls), 3)
self.assertEqual(self._read_cache(), {self.domain + ":A": self.address})
# A successful retry, unlike the failure, may suppress a subsequent update.
result, calls = self._sync_once([])
self.assertTrue(result)
self.assertEqual(calls, [])
def test_create_rate_limit_is_not_cached(self):
self._assert_write_failure_retries(False, 429)
def test_update_rate_limit_is_not_cached(self):
self._assert_write_failure_retries(True, 429)
def test_create_api_failure_is_not_cached(self):
self._assert_write_failure_retries(False, 200)
def test_update_api_failure_is_not_cached(self):
self._assert_write_failure_retries(True, 200)
def test_create_http_400_is_not_cached(self):
self._assert_write_failure_retries(False, 400)
def test_update_http_400_is_not_cached(self):
self._assert_write_failure_retries(True, 400)
def test_http_failure_overrides_success_envelope(self):
responses = self._lookup_responses(True) + [self._response({"success": True, "result": {"id": "rec123"}}, 429)]
result, calls = self._sync_once(responses)
self.assertFalse(result)
self.assertEqual(len(calls), 3)
self.assertEqual(self._read_cache(), {})
def test_record_query_failure_does_not_create(self):
for payload, status in ((self.api_error, 200), (self.api_error, 429), ({}, 200)):
responses = self._lookup_responses(False)[:1] + [
self._response(payload, status),
self._response({"success": True, "result": {"id": "unexpected-create"}}),
]
result, calls = self._sync_once(responses)
self.assertFalse(result)
self.assertEqual([call[0][0] for call in calls], ["GET", "GET"])
self.assertEqual(self._read_cache(), {})
def test_filtered_query_failure_does_not_fallback(self):
responses = self._lookup_responses(False)[:1] + [self._response(self.api_error)]
result, calls = self._sync_once(responses, extra={"proxied": False})
self.assertFalse(result)
self.assertEqual(len(calls), 2)
self.assertEqual(self._read_cache(), {})
def test_zone_query_failure_does_not_try_another_zone(self):
result, calls = self._sync_once([self._response({})])
self.assertFalse(result)
self.assertEqual(len(calls), 1)
self.assertEqual(self._read_cache(), {})
def test_successful_empty_record_list_creates(self):
responses = self._lookup_responses(False) + [self._response({"success": True, "result": {"id": "created"}})]
result, calls = self._sync_once(responses)
self.assertTrue(result)
self.assertEqual([call[0][0] for call in calls], ["GET", "GET", "POST"])
def test_successful_empty_zone_list_is_not_an_api_error(self):
provider = CloudflareProvider("", self.token)
self.mock_logger(provider)
with patch("ddns.provider._base.request", return_value=self._response({"success": True, "result": []})):
self.assertIsNone(provider._query_zone_id("example.com"))
def test_non_object_api_response_raises(self):
provider = CloudflareProvider("", self.token)
self.mock_logger(provider)
for payload in (None, [], "Invalid response"):
with patch("ddns.provider._base.request", return_value=self._response(payload)):
with self.assertRaises(RuntimeError):
provider._request("GET", "")
if __name__ == "__main__":
unittest.main()