Compare commits

...

13 Commits

Author SHA1 Message Date
67e58fc8ee split regex for PEP-8 2025-11-04 00:36:58 +01:00
f32267280a better log formatting 2025-11-04 00:32:40 +01:00
34e8ca8c95 parameterize ddns A record name 2025-11-04 00:32:24 +01:00
7b5ddcec21 Merge pull request #3 from KevinMidboe/feat/test
Feat: Tests 🧪
2024-02-12 12:35:35 +01:00
c4d4755eeb Unit tests for valid IP, public IP & CF API requests 2024-02-12 12:31:57 +01:00
8bb7f2e1ae Example env file 2024-02-12 12:27:05 +01:00
fe60206ef5 Requirements files for test & runtime 2024-02-12 12:26:52 +01:00
440f31fede Moved all source files to src/ 2024-02-12 12:26:09 +01:00
11ceb88f1b Make sure that what we are comparing is acutal IP structure 2024-02-11 23:49:27 +01:00
776881775b Only update when changed is True 2024-02-11 15:56:48 +01:00
cdfb4aef5f Send SMS to notify when IP cycles 2024-02-11 12:46:59 +01:00
fde88fd655 Updated CI kubernetes deploy environment perperation 2024-02-11 11:18:13 +01:00
a9957a43b8 Feat: Hydrate application environment variables from local vault (#3)
* Hydrate kubernetes secret w/ secrets from local vault

* Fix CI sourcing of env var file

* Compact and reduce output

* Make sure secret is defined before cronjob

* Create ghcr-login-secret from env variable injected from vault

* Import ghcr-login-secret namespace from NAMESPACE

* Export env variables for debugging

* Prepend export keyword to variables file

* Remove debug output
2024-02-10 14:33:45 +01:00
19 changed files with 433 additions and 87 deletions

View File

@@ -1,7 +1,25 @@
---
kind: pipeline
type: docker
name: Build
name: Test
platform:
os: linux
arch: amd64
steps:
- name: Run unit tests
image: python:3.10-alpine
commands:
- pip install -r requirements.txt
- pip install -r requirements-test.txt
- python3 -m unittest tests/test_*.py -v
---
kind: pipeline
type: docker
name: Publish
platform:
os: linux
@@ -12,23 +30,27 @@ steps:
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/cloudflare-ddns
repo: ghcr.io/kevinmidboe/${DRONE_REPO_NAME}
dockerfile: Dockerfile
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_PASSWORD
from_secret: GHCR_UPLOAD_TOKEN
tags:
- latest
- ${DRONE_COMMIT_SHA}
when:
event:
include:
- push
exclude:
- pull_request
branch:
- main
trigger:
event:
include:
- push
exclude:
- pull_request
branch:
- main
depends_on:
- Test
---
kind: pipeline
@@ -40,26 +62,65 @@ platform:
arch: amd64
steps:
- name: Prepare kubernetes environment
image: alpine/k8s:1.25.15
environment:
VAULT_TOKEN:
from_secret: VAULT_TOKEN
VAULT_HOST:
from_secret: VAULT_HOST
commands:
- mkdir -p /root/.kube
- echo "IMAGE=ghcr.io/kevinmidboe/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA}" > /root/.kube/.env
- echo "NAMESPACE=${DRONE_REPO_NAME}" >> /root/.kube/.env
- 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN"
$VAULT_HOST/v1/schleppe/data/kazan/_infra
| jq -r ".data.data.KUBE_CONFIG" > /root/.kube/config'
- 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN"
$VAULT_HOST/v1/schleppe/data/kazan/_infra
| jq -cr ".data.data | .[\"ghcr-login-secret\"] | @base64" > /root/.kube/dockerconfig.json'
- echo "DOCKER_CONFIG=$(cat /root/.kube/dockerconfig.json)" >> /root/.kube/.env
- 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN"
$VAULT_HOST/v1/schleppe/data/kazan/${DRONE_REPO_NAME}
| jq -cr ".data.data | to_entries[] | .key + \"=\" + (.value | @base64)" >> /root/.kube/.env'
- sed -i '/^$/!s/^/export /' /root/.kube/.env
volumes:
- name: kube-config
path: /root/.kube
- name: Deploy to kubernetes
image: alpine/k8s:1.25.15
commands:
- mkdir -p /root/.kube
- echo $KUBE_CONFIG | base64 -di > /root/.kube/config
- export IMAGE=ghcr.io/kevinmidboe/cloudflare-ddns:${DRONE_COMMIT_SHA}
- source /root/.kube/.env > /dev/null 2>&1
- cat .kubernetes/*.yml
| envsubst
| kubectl --kubeconfig=/root/.kube/config apply -f -
environment:
KUBE_CONFIG:
from_secret: KUBE_CONFIG
when:
event:
include:
- push
exclude:
- pull_request
branch:
- main
volumes:
- name: kube-config
path: /root/.kube
trigger:
event:
include:
- push
exclude:
- pull_request
branch:
- main
depends_on:
- Build
- Test
- Publish
volumes:
- name: kube-config
temp: {}
---
kind: signature
hmac: 2fb50ffa037eb368bcf6d596ced4c0ef42cfde413781ee39dd42b5f695396132
...

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
API_KEY=
DDNS_ZONE=
SMS_API_KEY=
SMS_RECIPIENT=

12
.kubernetes/1-secret.yml Normal file
View File

@@ -0,0 +1,12 @@
---
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: secret-env-values
namespace: cloudflare-ddns
data:
DDNS_ZONE: ${DDNS_ZONE}
API_KEY: ${API_KEY}
SMS_API_KEY: ${SMS_API_KEY}
SMS_RECIPIENT: ${SMS_RECIPIENT}

View File

@@ -1,3 +1,4 @@
---
apiVersion: batch/v1
kind: CronJob
metadata:

View File

@@ -0,0 +1,9 @@
---
apiVersion: v1
kind: Secret
metadata:
name: ghcr-login-secret
namespace: ${NAMESPACE}
data:
.dockerconfigjson: ${DOCKER_CONFIG}
type: kubernetes.io/dockerconfigjson

View File

@@ -1,6 +1,7 @@
FROM python:3.10-alpine
COPY *.py ./
COPY requirements.txt .
COPY src/*.py ./
RUN pip install requests python-dotenv
RUN pip install -r requirements.txt
CMD python3 main.py

View File

@@ -29,7 +29,7 @@ virtualenv
source -p $(which python3) env/bin/activate
pip install -r requirements.txt
python main.py
python src/main.py
```
## Kubernetes
@@ -46,8 +46,14 @@ metadata:
namespace: cloudflare-ddns
type: Opaque
data:
API_KEY: BASE_64_ENCODED_CLOUDFLARE_API_KEY
DDNS_ZONE: BASE64_ENCODED_CLOUDFLARE_ZONE_ID
API_KEY: CLOUDFLARE_API_KEY
DDNS_ZONE: CLOUDFLARE_ZONE_ID
```
(Optional: receive a SMS from gateway API by appending to secrets data)
```yaml
SMS_API_KEY: GATEWAY_API_API_KEY
SMS_RECIPIENT: PHONE_NUMBER_TO_RECEIVE
```
```

49
main.py
View File

@@ -1,49 +0,0 @@
import os
import requests
from bulk_dns_update import updateAllZones, setAPIKey, getDDNSAddresszoneId
from dotenv import load_dotenv
from logger import logger
load_dotenv()
currentIP = None
DDNS_ZONE = os.getenv('DDNS_ZONE')
def publicAddress():
global currentIP
logger.info('Getting public IP from ifconfg.me...')
r = requests.get('https://ifconfig.me')
currentIP = r.text
logger.info('Public IP: {}'.format(currentIP))
def cloudflareDDNS():
logger.info('Checking IP recorded in Cloudflare...')
ddnsRecord = getDDNSAddresszoneId(DDNS_ZONE)
recordedIP = ddnsRecord['content']
logger.info('Found ddns recorded IP: {}'.format(recordedIP))
if currentIP != recordedIP:
logger.info('Public IP has changed, updating all A records.')
updateAllZones(recordedIP, currentIP)
else:
logger.info('is same, exiting')
def main():
apiKey = os.getenv('API_KEY')
if apiKey is None:
raise Exception('In .env file or environment set Cloudflare variable: API_KEY')
if DDNS_ZONE is None:
raise Exception('In .env file or environment; set Cloudflare zone where addr. points to current IP.')
setAPIKey(apiKey)
publicAddress()
cloudflareDDNS()
if __name__ == '__main__':
main()

1
requirements-test.txt Normal file
View File

@@ -0,0 +1 @@
responses==0.24.1

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests==2.31.0
python-dotenv==1.0.1

7
src/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
import os
import sys
PROJECT_PATH = os.getcwd()
SOURCE_PATH = os.path.join(
PROJECT_PATH, "src"
)
sys.path.append(SOURCE_PATH)

View File

@@ -15,6 +15,7 @@ import requests
from logger import logger
API_KEY = ''
DDNS_A_RECORD_NAME = 'addr'
def setAPIKey(apiKey):
@@ -63,6 +64,13 @@ def getZones():
url = 'https://api.cloudflare.com/client/v4/zones'
data = cloudflareRequest(url)
if 'success' not in data and 'errors' not in data:
logger.info(
"Unexpected cloudflare error when getting zones, no response!")
logger.info("data:" + str(data))
raise Exception(
'Unexpected Cloudflare error, missing response! Check logs.')
if data['success'] is False:
logger.info('Request to cloudflare was unsuccessful, error:')
logger.info(data['errors'])
@@ -77,9 +85,17 @@ def getZones():
def getRecordsForZone(zoneId):
url = 'https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A'.format(zoneId)
url = 'https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=A'.format(
zoneId)
data = cloudflareRequest(url)
if 'success' not in data and 'errors' not in data:
logger.info(
"Unexpected cloudflare error when getting records, no response!")
logger.info("data:" + str(data))
raise Exception(
'Unexpected Cloudflare error, missing response! Check logs.')
if data['success'] is False:
logger.info('Request from cloudflare was unsuccessful, error:')
logger.info(data['errors'])
@@ -95,17 +111,20 @@ def getRecordsForZone(zoneId):
def getDDNSAddresszoneId(ddnsZone):
records = getRecordsForZone(ddnsZone)
ddns_re = r"^{}\.".format(DDNS_A_RECORD_NAME)
for record in records:
if not re.match(r"^addr\.", record['name']):
if not re.match(ddns_re, record['name']):
continue
return record
raise Exception('No ddns record found for zone: {}'.format(ddnsZone))
raise Exception('No \'{}\' DDNS record found for zone: {}'.format(
DDNS_A_RECORD_NAME, ddnsZone))
def updateRecord(zoneId, recordId, name, newIP, ttl, proxied):
url = 'https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}'.format(zoneId, recordId)
url = 'https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}'.format(
zoneId, recordId)
data = {
'type': 'A',
'name': name,
@@ -115,7 +134,8 @@ def updateRecord(zoneId, recordId, name, newIP, ttl, proxied):
}
response = cloudflareUpdateRequest(url, data)
logger.info('\tRecord updated: {}'.format('' if response['success'] is True else ''))
logger.info('\tRecord updated: {}'.format(
'' if response['success'] is True else ''))
def getMatchingRecordsForZone(zoneId, oldIP):
@@ -131,8 +151,10 @@ def updateZone(zone, oldIP, newIP):
return
for record in records:
logger.info('\tRecord {}: {} -> {}'.format(record['name'], record['content'], newIP))
updateRecord(zone['id'], record['id'], record['name'], newIP, record['ttl'], record['proxied'])
logger.info(
'\tRecord {}: {} -> {}'.format(record['name'], record['content'], newIP))
updateRecord(zone['id'], record['id'], record['name'],
newIP, record['ttl'], record['proxied'])
def updateAllZones(oldIP, newIP):

71
src/main.py Normal file
View File

@@ -0,0 +1,71 @@
import os
import re
import requests
from bulk_dns_update import updateAllZones, setAPIKey, getDDNSAddresszoneId
from notify import notify
from dotenv import load_dotenv
from logger import logger
load_dotenv()
DDNS_ZONE = os.getenv('DDNS_ZONE')
def validIP(ipString):
ipRegex = r'^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}' \
r'([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
return bool(re.search(ipRegex, str(ipString)))
def publicAddress():
logger.info('Getting public IP from ifconfg.me...')
r = requests.get('https://ifconfig.me')
if r.status_code != 200:
return
ip = r.text
if not validIP(ip):
return
logger.info('Public IP: {}'.format(ip))
return ip
def cloudflareDDNS():
logger.info('Checking IP recorded in Cloudflare...')
ddnsRecord = getDDNSAddresszoneId(DDNS_ZONE)
ip = ddnsRecord['content']
logger.info('Found ddns recorded IP: {}'.format(ip))
if not validIP(ip):
return
return ip
def main():
apiKey = os.getenv('API_KEY')
if apiKey is None:
raise Exception(
'In .env file or environment set Cloudflare variable: API_KEY')
if DDNS_ZONE is None:
raise Exception(
'In .env file or environment; set Cloudflare zone where addr. points to current IP.')
setAPIKey(apiKey)
currentIP = publicAddress()
recordedIP = cloudflareDDNS()
if currentIP == recordedIP or None in [currentIP, recordedIP]:
logger.info('is same, exiting')
return
logger.info('Public IP has changed, updating all A records.')
notify("IP changed to: {}. Updating all cloudflare zones!".format(currentIP))
updateAllZones(recordedIP, currentIP)
if __name__ == '__main__':
main()

42
src/notify.py Normal file
View File

@@ -0,0 +1,42 @@
import base64
import requests
from os import getenv
from json import dumps
from logger import logger
def notify(message):
SMS_API_KEY = getenv('SMS_API_KEY')
SMS_RECIPIENT = getenv('SMS_RECIPIENT')
if not SMS_API_KEY:
logger.info("No SMS API key found, not notifying")
return
if not SMS_RECIPIENT:
logger.info("No SMS recipient found, not notifying")
return
recipient = "47{}".format(SMS_RECIPIENT)
apiKey = base64.b64encode(SMS_API_KEY.encode("utf-8")).decode("utf-8")
logger.info("Notifying of IP change over SMS")
url = "https://gatewayapi.com/rest/mtsms"
payload = {
"sender": "Dynamic DNS",
"message": message,
"recipients": [{"msisdn": recipient}]
}
headers = {
"Host": "gatewayapi.com",
"Authorization": "Basic " + apiKey,
"Accept": "application/json",
"Content-Type": "application/json"
}
r = requests.post(url, data=dumps(payload), headers=headers)
response = r.json()
logger.info("Response from SMS api")
logger.info("Status: {} {}".format(str(r.status_code), str(r.reason)))
logger.info(response)

2
tests/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from src.logger import logger
logger.setLevel('WARNING')

View File

@@ -0,0 +1,80 @@
import re
import unittest
import responses
from src.main import cloudflareDDNS
MOCK_IP = '44.208.147.61'
CLOUDFLARE_GET_RECORDS_URL = re.compile(
r"https\:\/\/api.cloudflare.com\/client\/v4\/zones\/\w*\/dns_records\?type\=A")
CLOUDFLARE_ADDR_RECORD_EXISTS_RESPONSE = {
'success': True,
'result': [{
'content': MOCK_IP,
'name': 'addr.',
'id': 'id',
'ttl': 86400,
'proxied': True
}]
}
CLOUDFLARE_ADDR_RECORD_NONEXISTANT_RESPONSE = {
'success': True,
'result': [{
'content': MOCK_IP,
'name': None,
'id': 'id',
'ttl': 86400,
'proxied': True
}]
}
CLOUDFLARE_500_RESPONSE = {
'success': False,
'errors': 'someerror'
}
class TestCloudflareDNSRecordIP(unittest.TestCase):
@responses.activate
def test_successfull_response(self):
responses.add(responses.GET, CLOUDFLARE_GET_RECORDS_URL,
json=CLOUDFLARE_ADDR_RECORD_EXISTS_RESPONSE, status=200)
ip = cloudflareDDNS()
self.assertEqual(MOCK_IP, ip)
@responses.activate
def test_addr_record_exists(self):
responses.add(responses.GET, CLOUDFLARE_GET_RECORDS_URL,
json=CLOUDFLARE_ADDR_RECORD_NONEXISTANT_RESPONSE,
status=200)
self.assertRaises(Exception, cloudflareDDNS)
@responses.activate
def test_cloudflare_500_response(self):
responses.add(responses.GET, CLOUDFLARE_GET_RECORDS_URL,
json=CLOUDFLARE_500_RESPONSE,
status=500)
self.assertRaises(Exception, cloudflareDDNS)
@responses.activate
def test_cloudflare_empty_200_response(self):
responses.add(responses.GET, CLOUDFLARE_GET_RECORDS_URL,
json={},
status=500)
self.assertRaises(Exception, cloudflareDDNS)
@responses.activate
def test_cloudflare_empty_500_response(self):
responses.add(responses.GET, CLOUDFLARE_GET_RECORDS_URL,
json={},
status=500)
self.assertRaises(Exception, cloudflareDDNS)
if __name__ == '__main__':
unittest.main()

33
tests/test_ip_address.py Normal file
View File

@@ -0,0 +1,33 @@
import unittest
from src.main import validIP
MOCK_IP = "44.208.147.61"
class TestIPAddress(unittest.TestCase):
def test_valid_ip(self):
self.assertTrue(validIP(MOCK_IP))
def test_invalid_ip(self):
ip = "256.0.0.1"
self.assertFalse(validIP(ip))
def test_invalid_format(self):
ip = "192.168.1"
self.assertFalse(validIP(ip))
def test_empty_string(self):
ip = ""
self.assertFalse(validIP(ip))
def test_error_looking_string(self):
ip = "upstream connect error or disconnect/reset before headers. reset reason: connection timeout."
self.assertFalse(validIP(ip))
def test_none(self):
ip = None
self.assertFalse(validIP(ip))
if __name__ == "__main__":
unittest.main()

40
tests/test_public_ip.py Normal file
View File

@@ -0,0 +1,40 @@
import unittest
import responses
from src.main import publicAddress
MOCK_IP = '44.208.147.61'
MOCK_TIMEOUT = 'upstream connect error or disconnect/reset before headers. reset reason: connection timeout.'
class TestPublicAddress(unittest.TestCase):
@responses.activate
def test_successfull_response(self):
responses.add(responses.GET, 'https://ifconfig.me',
body=MOCK_IP, status=200)
ip = publicAddress()
self.assertEqual(MOCK_IP, ip)
@responses.activate
def test_timeout_response(self):
responses.add(responses.GET, 'https://ifconfig.me',
body=MOCK_TIMEOUT, status=500)
ip = publicAddress()
self.assertIsNone(ip)
@responses.activate
def test_mangled_response(self):
responses.add(responses.GET, 'https://ifconfig.me',
body='123.22', status=200)
ip = publicAddress()
self.assertIsNone(ip)
if __name__ == '__main__':
unittest.main()