diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..f2f802e --- /dev/null +++ b/.env.template @@ -0,0 +1,5 @@ +FUNCTION_URL= +SERVICE_ACCOUNT_KEYFILE= +HOSTNAME= +PROJECT= +ZONE= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26ac5d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +venv/ +keys/ +.idea/ +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbc3b4c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Dynamic Cloud DNS + +Simple yet secure Python script to update DNS record in Google Cloud DNS according to your current IP address. + +## Features +- Google API Clients & `gcloud` not required +- Updates Cloud DNS record using your current IP address +- Uses service account based authentication + +## What does it do exactly? + +This script updates a pre-defined DNS type "A" record in Google Cloud DNS according to the current public IP address of the caller. (i.e. your machine) + +When combined with a scheduler, this effectively acts as a Dynamic DNS (DDNS) service for Google Cloud DNS. + +## Installation + +1. Deploy the Cloud Functions (`/functions` of this repo) and grant it permissions to change Cloud DNS resources. +2. Create a service account for the script and grant it permission to invoke Cloud Functions. +3. Generate a new JSON keyfile of the created service account and store it into your machine. +4. Install all necessary python packages (i.e. `pip install -r requirements.txt`) + +## How to use + +1. Create and fill in the variables required in `.env` file. Refer to `.env.template`. +2. `python3 main.py` (or `python main.py` if you are using `virtualenv`) +3. (optional) Set a crontab to regularly update the DNS record set! \ No newline at end of file diff --git a/access_token.py b/access_token.py new file mode 100644 index 0000000..c263a54 --- /dev/null +++ b/access_token.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone, timedelta + +import jwt +import requests + + +def create_self_signed_token(audience, sa_email, private_key): + """ + Generate a self-signed JWT for obtaining access token from Google. + Refer to + https://cloud.google.com/functions/docs/securing/authenticating#generating_tokens_manually + :param audience: the URL/URI to be called later + :param sa_email: service account email + :param private_key: private key of the service account key + :return: a self signed JWT + """ + current_time = datetime.now(tz=timezone.utc) + jwt_payload = { + "target_audience": audience, + "iss": sa_email, + "sub": sa_email, + "exp": current_time + timedelta(seconds=30), + "iat": current_time, + "aud": "https://www.googleapis.com/oauth2/v4/token" + } + return jwt.encode(jwt_payload, private_key, algorithm="RS256") + + +def get_access_token(self_signed_token): + """ + Exchange self-signed JWT for a Google-signed JWT used as access token. + Refer to + https://cloud.google.com/functions/docs/securing/authenticating#generating_tokens_manually + :param self_signed_token :self signed token + :return: access token + """ + headers = { + "Authorization": "Bearer {}".format(self_signed_token), + "Content-Type": "application/x-www-form-urlencoded" + } + body = "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={}".format(self_signed_token) + response = requests.post("https://www.googleapis.com/oauth2/v4/token", headers=headers, data=body) + response_json = response.json() + return response_json.get('id_token') diff --git a/functions/README.md b/functions/README.md new file mode 100644 index 0000000..42038e0 --- /dev/null +++ b/functions/README.md @@ -0,0 +1,20 @@ +# Cloud Functions for DNS record set update + +## Description + +This is the code of the Cloud Function deployment for updaing Cloud DNS's record set. + +## Environment + +Python 3.9+ + +## Dependencies +```python +google-api-python-client==2.39.0 +``` + +## Note + +- This function is for updating only, and will not work if the record set for the requested hostname does not exist (For obvious reasons) +- **IMPORTANT** - Please ensure only allowing authenticated calls when deploying the function, otherwise very bad things can happen. +- **IMPORTANT** - Please ensure the function's service account does have proper permission to update record sets in Cloud DNS. \ No newline at end of file diff --git a/functions/main.py b/functions/main.py new file mode 100644 index 0000000..978cc6d --- /dev/null +++ b/functions/main.py @@ -0,0 +1,48 @@ +from googleapiclient import discovery + + +def update_dns_record(request): + """ + Update DNS record set to HTTP request with valid request body. + This is for updating only, and will not work if the record set + for the requested hostname does not exist (For obvious reasons) + Args: + request (flask.Request): HTTP request object. + Returns: + The response text or any set of values that can be turned into a + Response object using + `make_response `. + """ + src_ip = request.headers.get("X-Forwarded-For") + if not src_ip: + return "No IP provided in headers", 422 + + request_json = request.get_json() + if not request_json: + return "a request body must be provided", 400 + + project = request_json.get("project") + managed_zone = request_json.get("zone") + hostname = request_json.get("hostname") + + if not (project and managed_zone and hostname): + return "project, managed_zone & hostname must all be provided", 400 + + hostname_with_dot = hostname.rstrip(".") + "." + + record_set = { + "kind": "dns#resourceRecordSet", + "name": hostname_with_dot, + "type": "A", + "ttl": 300, + "rrdatas": src_ip + } + + try: + service = discovery.build('dns', 'v1') + request = service.resourceRecordSets().patch(project=project, managedZone=managed_zone, type="A", name=hostname_with_dot, body=record_set) + response = request.execute() + + return response, 200 + except Exception as e: + return "Error updating record. Reason: {}".format(str(e)), 500 diff --git a/functions/requirements.txt b/functions/requirements.txt new file mode 100644 index 0000000..0196ec4 --- /dev/null +++ b/functions/requirements.txt @@ -0,0 +1 @@ +google-api-python-client==2.39.0 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e580bc6 --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +import json + +from access_token import create_self_signed_token, get_access_token +import requests + +from environs import Env + +env = Env() +env.read_env() + +# required variables +cf_endpoint = env("FUNCTION_URL") +service_account_keyfile = env("SERVICE_ACCOUNT_KEYFILE") +hostname = env("HOSTNAME") +project = env("PROJECT") +zone = env("ZONE") + + +def main(): + # open keyfile and read content + with open(service_account_keyfile, 'r') as f: + credentials = json.loads(f.read()) + + sa_email = credentials.get('client_email') + private_key = credentials.get('private_key') + + # get access token + ss_token = create_self_signed_token(cf_endpoint, sa_email, private_key) + access_token = get_access_token(ss_token) + + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(access_token) + } + body = { + "project": project, + "zone": zone, + "hostname": hostname + } + + # send request to cloud function to update Cloud DNS record set + response = requests.post(cf_endpoint, headers=headers, json=body) + print(response) + print(response.content) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1ab6cb7 Binary files /dev/null and b/requirements.txt differ