mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-21 05:26:51 +00:00
Add No-IP (noip.com) Dynamic DNS Provider Support (#505)
* Initial plan * Implement No-IP (noip.com) provider with comprehensive tests and documentation Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Fix lint issues and address code review feedback Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Update README files to include No-IP provider documentation links Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> * Refactor No-IP documentation for clarity and consistency; update JSON schema to include additional DNS providers --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: New Future <NewFuture@users.noreply.github.com>
This commit is contained in:
co-authored by
NewFuture
New Future
parent
3d155b671e
commit
492e4fbf91
@@ -45,6 +45,7 @@
|
||||
- [HE.net](https://dns.he.net/) (@NN708) (不支持自动创建记录)
|
||||
- [华为云](https://huaweicloud.com/) (@cybmp3) ⚡
|
||||
- [腾讯云](https://cloud.tencent.com/) ([配置指南](doc/providers/tencentcloud.md)) ⚡
|
||||
- [No-IP](https://www.noip.com/) ([配置指南](doc/providers/noip.md))
|
||||
- 自定义回调 API ([配置指南](doc/providers/callback.md))
|
||||
|
||||
> ⚡ 标记的服务商使用高级 HMAC-SHA256 签名认证,提供企业级安全保障
|
||||
@@ -122,6 +123,7 @@
|
||||
- **HE.net**: [DDNS 文档](https://dns.he.net/docs.html)(仅需将设置的密码填入 `token` 字段,`id` 字段可留空)
|
||||
- **华为云 DNS**: [APIKEY 申请](https://console.huaweicloud.com/iam/)(点左边访问密钥,然后点新增访问密钥)
|
||||
- **腾讯云 DNS**: [详细配置文档](doc/providers/tencentcloud.md)
|
||||
- **No-IP**: [用户名和密码](https://www.noip.com/)(使用 No-IP 账户的用户名和密码) | [详细配置文档](doc/providers/noip.md)
|
||||
- **自定义回调**: 参数填写方式请查看下方的自定义回调配置说明
|
||||
|
||||
2. 修改配置文件,`ipv4` 和 `ipv6` 字段,为待更新的域名,详细参照配置说明
|
||||
@@ -172,7 +174,7 @@ python -m ddns -c /path/to/config.json
|
||||
| :----: | :----------------: | :------: | :---------: | :----------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | string | √ | 无 | api 访问 ID | Cloudflare 为邮箱(使用 Token 时留空)<br>HE.net 可留空<br>华为云为 Access Key ID (AK) |
|
||||
| token | string | √ | 无 | api 授权 token | 部分平台叫 secret key,**反馈粘贴时删除** |
|
||||
| dns | string | No | `"dnspod"` | dns 服务商 | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,腾讯云为 `tencentcloud`,自定义回调为 `callback`。部分服务商有[详细配置文档](doc/providers/) |
|
||||
| dns | string | No | `"dnspod"` | dns 服务商 | 阿里 DNS 为 `alidns`,Cloudflare 为 `cloudflare`,dns.com 为 `dnscom`,DNSPOD 国内为 `dnspod`,DNSPOD 国际为 `dnspod_com`,HE.net 为 `he`,华为云为 `huaweidns`,腾讯云为 `tencentcloud`,No-IP 为 `noip`,自定义回调为 `callback`。部分服务商有[详细配置文档](doc/providers/) |
|
||||
| ipv4 | array | No | `[]` | ipv4 域名列表 | 为 `[]` 时,不会获取和更新 IPv4 地址 |
|
||||
| ipv6 | array | No | `[]` | ipv6 域名列表 | 为 `[]` 时,不会获取和更新 IPv6 地址 |
|
||||
| index4 | string\|int\|array | No | `"default"` | ipv4 获取方式 | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式 |
|
||||
@@ -221,7 +223,7 @@ python -m ddns -c /path/to/config.json
|
||||
"$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
|
||||
"id": "12345",
|
||||
"token": "mytokenkey",
|
||||
"dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 tencentcloud 或 callback",
|
||||
"dns": "dnspod 或 dnspod_com 或 alidns 或 dnscom 或 cloudflare 或 he 或 huaweidns 或 tencentcloud 或 noip 或 callback",
|
||||
"ipv4": ["ddns.newfuture.cc", "ipv4.ddns.newfuture.cc"],
|
||||
"ipv6": ["ddns.newfuture.cc", "ipv6.ddns.newfuture.cc"],
|
||||
"index4": 0,
|
||||
|
||||
@@ -8,6 +8,7 @@ from .dnspod import DnspodProvider
|
||||
from .dnspod_com import DnspodComProvider
|
||||
from .he import HeProvider
|
||||
from .huaweidns import HuaweiDNSProvider
|
||||
from .noip import NoipProvider
|
||||
from .tencentcloud import TencentCloudProvider
|
||||
from .debug import DebugProvider
|
||||
|
||||
@@ -48,6 +49,10 @@ def get_provider_class(provider_name):
|
||||
"huaweidns": HuaweiDNSProvider,
|
||||
"huawei": HuaweiDNSProvider, # 兼容huawei
|
||||
"huaweicloud": HuaweiDNSProvider,
|
||||
# no-ip
|
||||
"noip": NoipProvider,
|
||||
"no-ip": NoipProvider, # 兼容no-ip
|
||||
"noip_com": NoipProvider, # 兼容noip.com
|
||||
# callback
|
||||
"callback": CallbackProvider,
|
||||
"webhook": CallbackProvider, # 兼容
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
No-IP (noip.com) Dynamic DNS API
|
||||
@author: GitHub Copilot
|
||||
"""
|
||||
|
||||
import base64
|
||||
from ._base import SimpleProvider, TYPE_FORM
|
||||
|
||||
|
||||
class NoipProvider(SimpleProvider):
|
||||
"""
|
||||
No-IP (www.noip.com) Dynamic DNS Provider
|
||||
|
||||
No-IP is a popular dynamic DNS service that provides simple HTTP-based
|
||||
API for updating DNS records. This provider supports the standard
|
||||
No-IP update protocol.
|
||||
"""
|
||||
|
||||
API = "https://dynupdate.no-ip.com"
|
||||
content_type = TYPE_FORM
|
||||
accept = None # No-IP returns plain text response
|
||||
decode_response = False # Response is plain text, not JSON
|
||||
|
||||
def _validate(self):
|
||||
"""
|
||||
Validate authentication credentials for No-IP
|
||||
"""
|
||||
if not self.auth_id:
|
||||
raise ValueError("No-IP requires username as 'id'")
|
||||
if not self.auth_token:
|
||||
raise ValueError("No-IP requires password as 'token'")
|
||||
|
||||
def set_record(self, domain, value, record_type="A", ttl=None,
|
||||
line=None, **extra):
|
||||
"""
|
||||
Update DNS record using No-IP Dynamic Update API
|
||||
|
||||
No-IP API Reference:
|
||||
- URL: https://dynupdate.no-ip.com/nic/update
|
||||
- Method: GET or POST
|
||||
- Authentication: HTTP Basic Auth (username:password)
|
||||
- Parameters:
|
||||
- hostname: The hostname to update
|
||||
- myip: The IP address to set (optional, uses client IP
|
||||
if not provided)
|
||||
|
||||
Response codes:
|
||||
- good: Update successful
|
||||
- nochg: IP address is current, no update performed
|
||||
- nohost: Hostname supplied does not exist
|
||||
- badauth: Invalid username/password combination
|
||||
- badagent: Client disabled
|
||||
- !donator: An update request was sent including a feature that
|
||||
is not available
|
||||
- abuse: Username is blocked due to abuse
|
||||
"""
|
||||
self.logger.info("%s => %s(%s)", domain, value, record_type)
|
||||
|
||||
# Prepare request parameters
|
||||
params = {
|
||||
"hostname": domain,
|
||||
"myip": value
|
||||
}
|
||||
|
||||
# Prepare HTTP Basic Authentication headers
|
||||
auth_string = "{0}:{1}".format(self.auth_id, self.auth_token)
|
||||
if hasattr(auth_string, 'encode'): # Python 3
|
||||
auth_bytes = auth_string.encode('utf-8')
|
||||
else: # Python 2
|
||||
auth_bytes = auth_string
|
||||
|
||||
auth_b64 = base64.b64encode(auth_bytes).decode('ascii')
|
||||
headers = {
|
||||
"Authorization": "Basic {0}".format(auth_b64),
|
||||
"User-Agent": "DDNS/{0} (ddns@newfuture.cc)".format(
|
||||
self.version)
|
||||
}
|
||||
|
||||
try:
|
||||
# Use GET request as it's the most common method for DDNS
|
||||
response = self._http("GET", "/nic/update", queries=params,
|
||||
headers=headers)
|
||||
|
||||
if response is not None:
|
||||
response_str = str(response).strip()
|
||||
self.logger.info("No-IP API response: %s", response_str)
|
||||
|
||||
# Check for successful responses
|
||||
if (response_str.startswith("good") or
|
||||
response_str.startswith("nochg")):
|
||||
return True
|
||||
elif response_str.startswith("nohost"):
|
||||
self.logger.error(
|
||||
"Hostname %s does not exist under No-IP account",
|
||||
domain)
|
||||
elif response_str.startswith("badauth"):
|
||||
self.logger.error(
|
||||
"Invalid No-IP username/password combination")
|
||||
elif response_str.startswith("badagent"):
|
||||
self.logger.error("No-IP client disabled")
|
||||
elif response_str.startswith("!donator"):
|
||||
self.logger.error(
|
||||
"Feature not available for No-IP free account")
|
||||
elif response_str.startswith("abuse"):
|
||||
self.logger.error("No-IP account blocked due to abuse")
|
||||
else:
|
||||
self.logger.error("Unexpected No-IP API response: %s",
|
||||
response_str)
|
||||
else:
|
||||
self.logger.error("Empty response from No-IP API")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Error updating No-IP record for %s: %s",
|
||||
domain, e)
|
||||
|
||||
return False
|
||||
@@ -11,6 +11,7 @@
|
||||
| `dnspod` | [DNSPod 中国版](https://www.dnspod.cn/) | [dnspod.md](dnspod.md) | [dnspod.en.md](dnspod.en.md) | 国内最大DNS服务商 |
|
||||
| `alidns` | [阿里云 DNS](https://dns.console.aliyun.com/) | [alidns.md](alidns.md) | [alidns.en.md](alidns.en.md) | 阿里云生态集成 |
|
||||
| `tencentcloud` | [腾讯云 DNSPod](https://cloud.tencent.com/product/cns) | [tencentcloud.md](tencentcloud.md) | [tencentcloud.en.md](tencentcloud.en.md) | 腾讯云DNSPod服务 |
|
||||
| `noip` | [No-IP](https://www.noip.com/) | [noip.md](noip.md) | [noip.en.md](noip.en.md) | 流行的动态DNS服务 |
|
||||
| `callback` | 自定义API (Webhook) | [callback.md](callback.md) | [callback.en.md](callback.en.md) | 自定义HTTP API |
|
||||
|
||||
### 其他支持的Provider
|
||||
@@ -64,6 +65,9 @@ ddns --dns cloudflare --id user@example.com --token your_api_key --ipv4 example.
|
||||
|
||||
# Cloudflare (使用Token)
|
||||
ddns --dns cloudflare --token your_api_token --ipv4 example.com
|
||||
|
||||
# No-IP
|
||||
ddns --dns noip --id your_username --token your_password --ipv4 example.com
|
||||
```
|
||||
|
||||
### JSON配置文件
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# No-IP Provider Configuration Guide
|
||||
|
||||
No-IP is a popular dynamic DNS service that supports the standard No-IP Dynamic Update Protocol.
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| `dns` | Provider name | ✅ | `"noip"` |
|
||||
| `id` | No-IP username or DDNS ID | ✅ | `"your_username"` |
|
||||
| `token` | No-IP password or DDNS KEY | ✅ | `"your_password"` |
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": "noip",
|
||||
"id": "your_username",
|
||||
"token": "your_password",
|
||||
"ipv4": ["home.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Domains
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": "noip",
|
||||
"id": "myusername",
|
||||
"token": "mypassword",
|
||||
"ipv4": [
|
||||
"home.example.com",
|
||||
"office.example.com"
|
||||
],
|
||||
"ipv6": ["ipv6.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Username and Password
|
||||
|
||||
Uses No-IP account username and password for authentication.
|
||||
|
||||
### DDNS KEY Authentication (Recommended)
|
||||
|
||||
Uses DDNS ID and DDNS KEY for authentication, which is more secure.
|
||||
|
||||
How to obtain: Login to [No-IP website](https://www.noip.com/) → Create Dynamic DNS hostname → Generate DDNS KEY
|
||||
|
||||
## Response Codes
|
||||
|
||||
| Response | Meaning | Status |
|
||||
|----------|---------|--------|
|
||||
| `good <ip>` | Update successful | ✅ |
|
||||
| `nochg <ip>` | IP unchanged | ✅ |
|
||||
| `nohost` | Hostname not found | ❌ |
|
||||
| `badauth` | Authentication failed | ❌ |
|
||||
| `badagent` | Client disabled | ❌ |
|
||||
| `!donator` | Paid account required | ❌ |
|
||||
| `abuse` | Account banned | ❌ |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Authentication failed (badauth)**: Check username and password
|
||||
- **Hostname not found (nohost)**: Check domain spelling
|
||||
- **Paid feature required (!donator)**: Upgrade account
|
||||
- **Account banned (abuse)**: Contact support
|
||||
|
||||
## Related Links
|
||||
|
||||
- [No-IP Website](https://www.noip.com/)
|
||||
- [API Documentation](https://www.noip.com/integrate/request)
|
||||
@@ -0,0 +1,75 @@
|
||||
# No-IP 配置指南
|
||||
|
||||
No-IP 是流行的动态 DNS 服务,支持标准的 No-IP 动态更新协议。
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 必需 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `dns` | 服务商名称 | ✅ | `"noip"` |
|
||||
| `id` | No-IP 用户名或 DDNS ID | ✅ | `"your_username"` |
|
||||
| `token` | No-IP 密码或 DDNS KEY | ✅ | `"your_password"` |
|
||||
|
||||
## 配置示例
|
||||
|
||||
### 基本配置
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": "noip",
|
||||
"id": "your_username",
|
||||
"token": "your_password",
|
||||
"ipv4": ["home.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
### 多个域名
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": "noip",
|
||||
"id": "myusername",
|
||||
"token": "mypassword",
|
||||
"ipv4": [
|
||||
"home.example.com",
|
||||
"office.example.com"
|
||||
],
|
||||
"ipv6": ["ipv6.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
## 认证方式
|
||||
|
||||
### 用户名密码认证
|
||||
|
||||
使用 No-IP 账户用户名和密码进行认证。
|
||||
|
||||
### DDNS KEY 认证(推荐)
|
||||
|
||||
使用 DDNS ID 和 DDNS KEY 进行认证,更安全。
|
||||
|
||||
获取方式:登录 [No-IP 官网](https://www.noip.com/) → 创建动态 DNS 主机名 → 生成 DDNS KEY
|
||||
|
||||
## 响应代码
|
||||
|
||||
| 响应 | 含义 | 状态 |
|
||||
|------|------|------|
|
||||
| `good <ip>` | 更新成功 | ✅ |
|
||||
| `nochg <ip>` | IP 无变化 | ✅ |
|
||||
| `nohost` | 主机名不存在 | ❌ |
|
||||
| `badauth` | 认证失败 | ❌ |
|
||||
| `badagent` | 客户端被禁用 | ❌ |
|
||||
| `!donator` | 需要付费账户 | ❌ |
|
||||
| `abuse` | 账户被封禁 | ❌ |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **认证失败 (badauth)**:检查用户名和密码
|
||||
- **主机名不存在 (nohost)**:检查域名拼写
|
||||
- **需要付费功能 (!donator)**:升级账户
|
||||
- **账户被封 (abuse)**:联系客服
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [No-IP 官网](https://www.noip.com/)
|
||||
- [API 文档](https://www.noip.com/integrate/request)
|
||||
+5
-4
@@ -42,15 +42,16 @@
|
||||
"cloudflare"
|
||||
],
|
||||
"enum": [
|
||||
"dnspod",
|
||||
"alidns",
|
||||
"callback",
|
||||
"cloudflare",
|
||||
"dnspod_com",
|
||||
"debug",
|
||||
"dnscom",
|
||||
"dnspod_com",
|
||||
"dnspod",
|
||||
"he",
|
||||
"huaweidns",
|
||||
"callback",
|
||||
"debug",
|
||||
"noip",
|
||||
"tencentcloud"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
Unit tests for NoipProvider (No-IP)
|
||||
|
||||
@author: GitHub Copilot
|
||||
"""
|
||||
|
||||
from base_test import BaseProviderTestCase, unittest, patch, MagicMock
|
||||
from ddns.provider.noip import NoipProvider
|
||||
|
||||
|
||||
class TestNoipProvider(BaseProviderTestCase):
|
||||
"""Test cases for NoipProvider"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
super(TestNoipProvider, self).setUp()
|
||||
# No-IP uses both auth_id (username) and auth_token (password)
|
||||
self.auth_id = "test_username"
|
||||
self.auth_token = "test_password"
|
||||
|
||||
def test_init_with_basic_config(self):
|
||||
"""Test NoipProvider initialization with basic configuration"""
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
self.assertEqual(provider.auth_id, self.auth_id)
|
||||
self.assertEqual(provider.auth_token, self.auth_token)
|
||||
self.assertEqual(provider.API, "https://dynupdate.no-ip.com")
|
||||
self.assertFalse(provider.decode_response)
|
||||
|
||||
def test_class_constants(self):
|
||||
"""Test NoipProvider class constants"""
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
self.assertEqual(provider.API, "https://dynupdate.no-ip.com")
|
||||
self.assertFalse(provider.decode_response)
|
||||
self.assertIsNone(provider.accept)
|
||||
# ContentType should be form-encoded
|
||||
from ddns.provider._base import TYPE_FORM
|
||||
self.assertEqual(provider.content_type, TYPE_FORM)
|
||||
|
||||
def test_validate_success_with_credentials(self):
|
||||
"""Test _validate method passes with proper credentials"""
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
# Should not raise any exception
|
||||
provider._validate()
|
||||
|
||||
def test_validate_fails_without_auth_id(self):
|
||||
"""Test _validate method fails when auth_id is missing"""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
NoipProvider("", self.auth_token)
|
||||
self.assertIn("No-IP requires username", str(cm.exception))
|
||||
|
||||
def test_validate_fails_without_auth_token(self):
|
||||
"""Test _validate method fails when auth_token is missing"""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
NoipProvider(self.auth_id, "")
|
||||
self.assertIn("No-IP requires password", str(cm.exception))
|
||||
|
||||
def test_validate_fails_with_both_missing(self):
|
||||
"""Test _validate method fails when both credentials are missing"""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
NoipProvider("", "")
|
||||
self.assertIn("No-IP requires username", str(cm.exception))
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_success_good_response(self, mock_http):
|
||||
"""Test set_record method with 'good' response"""
|
||||
mock_http.return_value = "good 192.168.1.1"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1", "A")
|
||||
|
||||
# Verify the result
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify _http was called with correct parameters
|
||||
mock_http.assert_called_once()
|
||||
args, kwargs = mock_http.call_args
|
||||
self.assertEqual(args[0], "GET") # method
|
||||
self.assertEqual(args[1], "/nic/update") # path
|
||||
|
||||
# Check query parameters
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "example.com")
|
||||
self.assertEqual(queries["myip"], "192.168.1.1")
|
||||
|
||||
# Check authentication header
|
||||
headers = kwargs["headers"]
|
||||
self.assertIn("Authorization", headers)
|
||||
self.assertTrue(headers["Authorization"].startswith("Basic "))
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_success_nochg_response(self, mock_http):
|
||||
"""Test set_record method with 'nochg' response"""
|
||||
mock_http.return_value = "nochg 192.168.1.1"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
|
||||
result = provider.set_record("test.example.com", "192.168.1.1", "A")
|
||||
|
||||
# Verify the result
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify _http was called with correct parameters
|
||||
mock_http.assert_called_once()
|
||||
args, kwargs = mock_http.call_args
|
||||
self.assertEqual(args[0], "GET")
|
||||
self.assertEqual(args[1], "/nic/update")
|
||||
|
||||
# Check query parameters
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "test.example.com")
|
||||
self.assertEqual(queries["myip"], "192.168.1.1")
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_ipv6_address(self, mock_http):
|
||||
"""Test set_record method with IPv6 address"""
|
||||
mock_http.return_value = "good 2001:db8::1"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
|
||||
result = provider.set_record("ipv6.example.com", "2001:db8::1", "AAAA")
|
||||
|
||||
# Verify the result
|
||||
self.assertTrue(result)
|
||||
|
||||
# Check query parameters
|
||||
args, kwargs = mock_http.call_args
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "ipv6.example.com")
|
||||
self.assertEqual(queries["myip"], "2001:db8::1")
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_with_all_parameters(self, mock_http):
|
||||
"""Test set_record method with all optional parameters"""
|
||||
mock_http.return_value = "good 10.0.0.1"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
|
||||
result = provider.set_record(
|
||||
domain="full.example.com",
|
||||
value="10.0.0.1",
|
||||
record_type="A",
|
||||
ttl=300,
|
||||
line="default",
|
||||
extra_param="test"
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
self.assertTrue(result)
|
||||
|
||||
# Check that core parameters are still correct
|
||||
args, kwargs = mock_http.call_args
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "full.example.com")
|
||||
self.assertEqual(queries["myip"], "10.0.0.1")
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_nohost_error(self, mock_http):
|
||||
"""Test set_record method with 'nohost' error response"""
|
||||
mock_http.return_value = "nohost"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("does not exist", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_badauth_error(self, mock_http):
|
||||
"""Test set_record method with 'badauth' error response"""
|
||||
mock_http.return_value = "badauth"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("Invalid No-IP username/password", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_badagent_error(self, mock_http):
|
||||
"""Test set_record method with 'badagent' error response"""
|
||||
mock_http.return_value = "badagent"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("client disabled", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_donator_error(self, mock_http):
|
||||
"""Test set_record method with '!donator' error response"""
|
||||
mock_http.return_value = "!donator"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("not available for No-IP free account", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_abuse_error(self, mock_http):
|
||||
"""Test set_record method with 'abuse' error response"""
|
||||
mock_http.return_value = "abuse"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("blocked due to abuse", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_unexpected_response(self, mock_http):
|
||||
"""Test set_record method with unexpected response"""
|
||||
mock_http.return_value = "unknown_response"
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("Unexpected No-IP API response", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_empty_response_error(self, mock_http):
|
||||
"""Test set_record method with empty response"""
|
||||
mock_http.return_value = ""
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged - empty string should be treated as
|
||||
# unexpected response
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("Unexpected No-IP API response", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_none_response_error(self, mock_http):
|
||||
"""Test set_record method with None response"""
|
||||
mock_http.return_value = None
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertIn("Empty response from No-IP API", args[0])
|
||||
|
||||
@patch.object(NoipProvider, "_http")
|
||||
def test_set_record_http_exception(self, mock_http):
|
||||
"""Test set_record method when _http raises an exception"""
|
||||
mock_http.side_effect = Exception("Network error")
|
||||
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
result = provider.set_record("example.com", "192.168.1.1")
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify error was logged
|
||||
provider.logger.error.assert_called_once()
|
||||
args = provider.logger.error.call_args[0]
|
||||
self.assertEqual(args[0], "Error updating No-IP record for %s: %s")
|
||||
self.assertEqual(args[1], "example.com")
|
||||
self.assertIsInstance(args[2], Exception)
|
||||
|
||||
def test_authentication_header_creation(self):
|
||||
"""Test that authentication header is created correctly"""
|
||||
provider = NoipProvider("test_user", "test_pass")
|
||||
|
||||
# Test the auth header creation manually
|
||||
import base64
|
||||
auth_string = "test_user:test_pass"
|
||||
if hasattr(auth_string, 'encode'): # Python 3
|
||||
auth_bytes = auth_string.encode('utf-8')
|
||||
else: # Python 2
|
||||
auth_bytes = auth_string
|
||||
|
||||
expected_auth_b64 = base64.b64encode(auth_bytes).decode('ascii')
|
||||
expected_header = "Basic {0}".format(expected_auth_b64)
|
||||
|
||||
with patch.object(provider, "_http") as mock_http:
|
||||
mock_http.return_value = "good 1.2.3.4"
|
||||
provider.set_record("test.com", "1.2.3.4")
|
||||
|
||||
# Check that the Authorization header was set correctly
|
||||
args, kwargs = mock_http.call_args
|
||||
headers = kwargs["headers"]
|
||||
self.assertEqual(headers["Authorization"], expected_header)
|
||||
|
||||
def test_set_record_logger_info_called(self):
|
||||
"""Test that logger.info is called with correct parameters"""
|
||||
provider = NoipProvider(self.auth_id, self.auth_token)
|
||||
provider.logger = MagicMock()
|
||||
|
||||
with patch.object(provider, "_http") as mock_http:
|
||||
mock_http.return_value = "good 192.168.1.1"
|
||||
provider.set_record("example.com", "192.168.1.1", "A")
|
||||
|
||||
# Verify logger.info was called for initial log
|
||||
provider.logger.info.assert_any_call(
|
||||
"%s => %s(%s)", "example.com", "192.168.1.1", "A")
|
||||
|
||||
|
||||
class TestNoipProviderIntegration(BaseProviderTestCase):
|
||||
"""Integration tests for NoipProvider"""
|
||||
|
||||
def test_full_workflow_ipv4_success(self):
|
||||
"""Test complete workflow for IPv4 record with success response"""
|
||||
provider = NoipProvider("test_user", "test_pass")
|
||||
|
||||
with patch.object(provider, "_http") as mock_http:
|
||||
mock_http.return_value = "good 1.2.3.4"
|
||||
|
||||
result = provider.set_record(
|
||||
"test.com", "1.2.3.4", "A", 300, "default")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_http.assert_called_once()
|
||||
args, kwargs = mock_http.call_args
|
||||
self.assertEqual(args[0], "GET")
|
||||
self.assertEqual(args[1], "/nic/update")
|
||||
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "test.com")
|
||||
self.assertEqual(queries["myip"], "1.2.3.4")
|
||||
|
||||
headers = kwargs["headers"]
|
||||
self.assertIn("Authorization", headers)
|
||||
self.assertIn("User-Agent", headers)
|
||||
|
||||
def test_full_workflow_ipv6_success(self):
|
||||
"""Test complete workflow for IPv6 record with success response"""
|
||||
provider = NoipProvider("test_user", "test_pass")
|
||||
|
||||
with patch.object(provider, "_http") as mock_http:
|
||||
mock_http.return_value = "good ::1"
|
||||
|
||||
result = provider.set_record(
|
||||
"test.com", "::1", "AAAA", 600, "telecom")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_http.assert_called_once()
|
||||
|
||||
args, kwargs = mock_http.call_args
|
||||
queries = kwargs["queries"]
|
||||
self.assertEqual(queries["hostname"], "test.com")
|
||||
self.assertEqual(queries["myip"], "::1")
|
||||
|
||||
def test_full_workflow_error_handling(self):
|
||||
"""Test complete workflow with error handling"""
|
||||
provider = NoipProvider("test_user", "test_pass")
|
||||
|
||||
with patch.object(provider, "_http") as mock_http:
|
||||
mock_http.return_value = "badauth"
|
||||
|
||||
result = provider.set_record("test.com", "1.2.3.4", "A")
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user