Commit 0fa3c4d2 authored by Bernhard Geier's avatar Bernhard Geier
Browse files

Initial commit

parents
# Install nsupdate.info on Debian with Ansible
This role installs everything you need to run your own dynamic DNS service:
* nsupdate.info as main service
* PostgreSQL as database
* Nginx with uwsgi as webserver
* Bind9 for DNS
* certbot for TLS certificates
* Postfix as local mail relay
I found the installation of nsupdate.info pretty hard and the documentation too brief, so I used Ansible to document my experiences. Study, share, improve :)
#### Requirements:
* Server with Debian 10 or 11 and fixed IPv4+IPv6 address (e.g. `12.34.56.78` and `2a03:1000:53:123::12`)
* A domain (e.g. `example.com`)
* Some DNS entries: If you own the domain "example.com" and want to run a dynamic DNS service on "dyn.example.com" then set this entries in the nameserver of "example.com":
* A and AAAA records for this server:
* `dyn.example.com A 12.34.56.78`
* `dyn.example.com AAAA 2a03:1000:53:123::12`
* NS record to this server:
* `dyn.example.com NS dyn.example.com`
* If you use a secondary DNS server then additionally a NS rercord pointing to that server:
* `dyn.example.com NS ljhgl2389ukjdhkwhd239uesadswd.free.ns.buddyns.com`
##### Configuration:
See `host_vars/dyn.example.com`
# Requirements:
# - Server has a fixed IP address (IPv4+IPv6)
# - DNS has A and AAAA records for this server (e.g. dyn.example.com A 12.34.56.78, dyn.example.com AAAA 2a03:1000:53:123::12)
# - DNS has a NS record set for dyn.example.com pointing to this server (e.g. dyn.example.com NS dyn.example.com)
# - If you use a secondary NS add a seconds NS entry to your DNS pointing to that server (e.g. dyn.example.com NS ljhgl2389ukjdhkwhd239uesadswd.free.ns.buddyns.com)
nsupdate:
# Randomly generated django secret key
# Used internally by Django for security stuff
secret_key: SECRETKEY
# Display service contact mail address
service_contact: "contact@example.com"
# Sender mail address (default: nsupdate.service_contact)
default_from_email: "noreply@example.com"
# Admin account
django_superuser:
username: superuser
password: superpass
email: root@geierb.de
# Enable user registration (default: false)
registration_open: true
# Domain
basedomain:
# this server's host name = your first dyndns domain
name: dyn.example.com
# free to use for all users (default: false)
public: true
# available for users (default: false)
available: true
secondary_ns:
# Optional: if you have a secondary DNS server (highly recommended!) enter its hostname here
# (there are free secondary DNS server services, e.g. buddyns.com)
#hostname: ljhgl2389ukjdhkwhd239uesadswd.free.ns.buddyns.com
# Optional: set your secondary DNS server's IP addresses.
# This limits allowed DNS zone transfers in Bind9 to this server, and nsupdate will try to update this server immediately on its own
#ipv4: 123.456.123.456
#ipv6: "abcd:000::1234:5678"
# Set username and password for PostgreSQL
postgresql:
username: nsupdate
password: TOPSECRET
# Configure SMTP server
mail:
# Name of SMTP server
smtp: "mail.example.com"
# Username and password
user: "user123"
pw: "myMailPwd123"
[nsupdate]
dyn.example.com ansible_ssh_host=102.51.242.239
- hosts: nsupdate
remote_user: root
roles:
- postfix
- nginx
- certbot
- bind9
- postgresql
- nsupdate_info
- name: increment zone serial
lineinfile:
path: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
regexp: '(?i)^(\s*)\d+(\s*;\s*serial.*)$'
backrefs: yes
line: "\\g<1>{{ bind_serial | default(1) | int +1 }}\\g<2>"
- name: restart bind9
service:
name: bind9
state: restarted
- name: rndc reload
command: /usr/sbin/rndc reload
- name: rndc thaw basedomain
command: /usr/sbin/rndc thaw {{ nsupdate.basedomain.name }}
- name: Install bind9
apt:
pkg: ['bind9']
install_recommends: no
- name: Create named.conf
template:
src: "named.conf.j2"
dest: "/etc/bind/named.conf"
mode: 0644
owner: root
group: bind
notify: rndc reload
### named.basedomain configuration
- name: Check if config for basedomain already exists
stat:
path: "/etc/bind/named.conf.{{ nsupdate.basedomain.name }}"
register: basecfg_stat
- name: Fetch existing basedomain config file
slurp:
src: "/etc/bind/named.conf.{{ nsupdate.basedomain.name }}"
register: basecfg_file
when: basecfg_stat.stat.exists
- name: Read current secret from existing basedomain config file
set_fact:
basedomain_secret: "{{ basecfg_file['content'] | b64decode | regex_findall('secret \"(\\S+)\";') | first }}"
when: basecfg_stat.stat.exists
- name: Create new secret
set_fact:
basedomain_secret: "{{ lookup('password', '/dev/null length=64') | b64encode }}"
when: basedomain_secret is not defined
- name: Create config for basedomain
template:
src: "named-basedomain.j2"
dest: "/etc/bind/named.conf.{{ nsupdate.basedomain.name }}"
mode: 0644
owner: root
group: bind
notify: rndc reload
### zone file
- name: Check if zone file for basedomain already exists
stat:
path: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
register: zone_stat
# if zonefile exists: modify existing zone file
- name: Freeze existing basedomain zone file
command: "/usr/sbin/rndc freeze {{ nsupdate.basedomain.name }}"
notify: rndc thaw basedomain
when: zone_stat.stat.exists
- name: Fetch existing basedomain zone file
slurp:
src: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
register: db_file
when: zone_stat.stat.exists
- name: Read current serial from existing local zonefile
set_fact:
bind_serial: "{{ db_file['content'] | b64decode | regex_findall('(\\d+)\\s*;\\s*serial',ignorecase=True) | first | default(1) }}"
when: zone_stat.stat.exists
- name: Build regex to find secondary NS in zone file
set_fact:
ns2_reg: "\\s+NS\\s*((?:(?!{{ nsupdate.basedomain.name | regex_replace('\\.','\\.') }})\\S)*)\\."
- name: Read secondary NS record from existing local zonefile
set_fact:
bind_ns2_record: "{{ db_file['content'] | b64decode | regex_findall(ns2_reg) | first | default(omit) }}"
when: zone_stat.stat.exists
- name: Remove secondary NS
lineinfile:
path: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
regexp: "^\\s+NS\\s+{{ bind_ns2_record }}\\.\\s*$"
state: absent
when: zone_stat.stat.exists and
bind_ns2_record is defined and
bind_ns2_record | default(false) != nsupdate.basedomain.secondary_ns.hostname | default(false)
notify: increment zone serial
- name: Add secondary NS
lineinfile:
path: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
insertafter: "^\\s+NS\\s+{{ nsupdate.basedomain.name }}\\.\\s*$"
line: " NS {{ nsupdate.basedomain.secondary_ns.hostname }}."
when: zone_stat.stat.exists and
nsupdate.basedomain.secondary_ns.hostname is defined and
bind_ns2_record | default(false) != nsupdate.basedomain.secondary_ns.hostname | default(false)
notify: increment zone serial
# if zonefile does not exist: create new zone file
- name: Create zone file for basedomain
template:
src: "db.basedomain.j2"
dest: "/var/lib/bind/{{ nsupdate.basedomain.name }}"
group: bind
mode: "0640"
when: not zone_stat.stat.exists
notify: rndc reload
; {{ ansible_managed }}
$ORIGIN .
$TTL 3600
{{ nsupdate.basedomain.name }} IN SOA {{ nsupdate.basedomain.name }}. {{ nsupdate.service_contact | regex_replace('@', '.') }}. (
{{ bind_serial | default(1) }} ; serial
7200 ; refresh
1800 ; retry
604800 ; expire
60 ; minimum
)
NS {{ nsupdate.basedomain.name }}.
{% if nsupdate.basedomain.secondary_ns.hostname is defined %}
NS {{ nsupdate.basedomain.secondary_ns.hostname }}.
{% endif %}
A {{ ansible_default_ipv4.address }}
AAAA {{ ansible_default_ipv6.address }}
$ORIGIN {{ nsupdate.basedomain.name }}.
$TTL 3600
www IN A {{ ansible_default_ipv4.address }}
www IN AAAA {{ ansible_default_ipv6.address }}
ipv4 IN A {{ ansible_default_ipv4.address }}
ipv6 IN AAAA {{ ansible_default_ipv6.address }}
// {{ ansible_managed }}
key "{{ nsupdate.basedomain.name }}." {
// everyone who has this key may update this zone:
// must be same algorithm as in the Domain record of the nsupdate.info based service
algorithm hmac-sha512;
// the secret is just a shared secret in base64-encoding, you don't need
// to use a special tool to create it. Some random in base64 encoding should
// be OK. must be same secret as in the Domain database record of the nsupdate.info based service
secret "{{ basedomain_secret }}";
};
zone {{ nsupdate.basedomain.name }} {
{% if nsupdate.basedomain.secondary_ns.ipv4 is defined or nsupdate.basedomain.secondary_ns.ipv6 is defined %}
// restrict zone transfer to secondary nameserver
allow-transfer { {{ [ nsupdate.basedomain.secondary_ns.ipv4 | default(), nsupdate.basedomain.secondary_ns.ipv6 | default() ] | select() | join(";") }}; };
{% endif %}
type master;
// bind9 needs write permissions into that directory and into that file:
file "/var/lib/bind/{{ nsupdate.basedomain.name }}";
update-policy {
// these "deny" entries are needed for the service domain,
// if you add another domain, you may want to check the need
// for other "deny" entries if the zone is not fully available.
// we don't allow updates to the infrastructure hosts:
deny {{ nsupdate.basedomain.name }}. name {{ nsupdate.basedomain.name }};
deny {{ nsupdate.basedomain.name }}. name www.{{ nsupdate.basedomain.name }};
deny {{ nsupdate.basedomain.name }}. name ipv4.{{ nsupdate.basedomain.name }};
deny {{ nsupdate.basedomain.name }}. name ipv6.{{ nsupdate.basedomain.name }};
// this host is for testing if the nameserver is configured correctly and reachable
grant {{ nsupdate.basedomain.name }}. name connectivity-test.{{ nsupdate.basedomain.name }} A;
// but we allow updates to any other host:
grant {{ nsupdate.basedomain.name }}. subdomain {{ nsupdate.basedomain.name }};
};
};
// {{ ansible_managed }}
// This is the primary configuration file for the BIND DNS server named.
//
// Please read /usr/share/doc/bind9/README.Debian.gz for information on the
// structure of BIND configuration files in Debian, *BEFORE* you customize
// this configuration file.
//
// If you are just adding zones, please do that in /etc/bind/named.conf.local
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
include "/etc/bind/named.conf.default-zones";
include "/etc/bind/named.conf.{{ nsupdate.basedomain.name }}";
- name: Install Certbot
apt:
pkg: [ 'certbot', 'python3-certbot-apache' ]
- name: Check if certificate is already existing
stat:
path: /etc/letsencrypt/live/{{ nsupdate.basedomain.name }}/cert.pem
register: letsencrypt_cert
- name: Stop Nginx to allow certbot to generate a cert
service:
name: "nginx"
state: stopped
when: not letsencrypt_cert.stat.exists
- name: Generate new certificate
command: >
certbot certonly --standalone --noninteractive --agree-tos --email {{ nsupdate.service_contact }}
-d {{ nsupdate.basedomain.name }},www.{{ nsupdate.basedomain.name }},ipv4.{{ nsupdate.basedomain.name }},ipv6.{{ nsupdate.basedomain.name }}
when: not letsencrypt_cert.stat.exists
- name: Start Nginx after cert has been generated
service:
name: "nginx"
state: started
when: not letsencrypt_cert.stat.exists
- name: Add hook to certbot to reload Nginx after certificate renewal
template:
src: "renewal-hook-nginx-reload.sh.j2"
dest: "/etc/letsencrypt/renewal-hooks/nginx.sh"
mode: 0770
#!/bin/sh
# {{ ansible_managed }}
systemctl reload nginx
- name: reload nginx
service:
name: nginx
state: reloaded
- name: Install nginx-light
apt:
pkg: ['nginx-light']
install_recommends: no
- name: Create nsupdate.info directory
file:
name: /var/www/nsupdate.info
state: directory
mode: 0755
- name: Configure access_log anonymization
template:
src: templates/anonymize.conf.j2
dest: /etc/nginx/conf.d/anonymize.conf
mode: 0644
notify: reload nginx
- name: Install nsupdate.info nginx site
template:
src: templates/nsupdate.info.j2
dest: /etc/nginx/sites-available/nsupdate.info
mode: 0644
notify: reload nginx
- name: Activate nsupdate.info nginx site
file:
src: /etc/nginx/sites-available/nsupdate.info
dest: /etc/nginx/sites-enabled/nsupdate.info
state: link
notify: reload nginx
# {{ ansible_managed }}
map $remote_addr $ip_anonym1 {
default 0.0.0;
"~(?P<ip>(\d+)\.(\d+))\.(\d+)\.\d+" $ip;
"~(?P<ip>[^:]+:[^:]+):" $ip;
}
map $remote_addr $ip_anonym2 {
default .0.0;
"~(?P<ip>(\d+)\.(\d+)\.(\d+))\.\d+" .0.0;
"~(?P<ip>[^:]+:[^:]+):" ::;
}
map $ip_anonym1$ip_anonym2 $ip_anonymized {
default 0.0.0.0;
"~(?P<ip>.*)" $ip;
}
log_format anonymized '$ip_anonymized - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
# {{ ansible_managed }}
# Redirect from http(s)://{{ nsupdate.basedomain.name }} to http(s)://www.{{ nsupdate.basedomain.name }}
server {
listen 0.0.0.0:80;
listen [::]:80;
listen 0.0.0.0:443 ssl;
listen [::]:443 ssl;
server_name {{ nsupdate.basedomain.name }};
access_log /var/log/nginx/access.log anonymized;
# Mozilla Guideline v5.4, nginx 1.17.7, OpenSSL 1.1.1d, intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 5m;
ssl_session_cache shared:SSL:10m; # about 40000 sessions
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000" always;
ssl_certificate /etc/letsencrypt/live/{{ nsupdate.basedomain.name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ nsupdate.basedomain.name }}/privkey.pem;
return 301 $scheme://www.{{ nsupdate.basedomain.name }}$request_uri;
}
server {
listen 0.0.0.0:80;
listen [::]:80;
listen 0.0.0.0:443 ssl;
listen [::]:443 ssl;
server_name www.{{ nsupdate.basedomain.name }} ipv4.{{ nsupdate.basedomain.name }} ipv6.{{ nsupdate.basedomain.name }};
access_log /var/log/nginx/access.log anonymized;
# Mozilla Guideline v5.4, nginx 1.17.7, OpenSSL 1.1.1d, intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 5m;
ssl_session_cache shared:SSL:10m; # about 40000 sessions
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000" always;
ssl_certificate /etc/letsencrypt/live/{{ nsupdate.basedomain.name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ nsupdate.basedomain.name }}/privkey.pem;
location /static/ {
alias /var/www/nsupdate.info/static/;
}
location /myip {
add_header Content-Type text/plain;
return 200 $remote_addr;
}
location / {
include uwsgi_params;
uwsgi_pass unix:/run/uwsgi/uwsgi.sock;
}
}
- name: restart uwsgi
systemd:
name: uwsgi
state: restarted
# insert or update domain in database
- name: Ansible Workaround - If IPv4 of secondary nameserver is undefined, set variable explicitely to NULL
set_fact:
db_secondary_ns_ipv4: NULL
when: nsupdate.basedomain.secondary_ns.ipv4 is undefined
- name: Ansible Workaround - If IPv4 of secondary nameserver is defined, set variable to that value
set_fact:
db_secondary_ns_ipv4: "{{ nsupdate.basedomain.secondary_ns.ipv4 }}"
when: nsupdate.basedomain.secondary_ns.ipv4 is defined
- name: Check if basedomain is already in database
postgresql_query:
db: nsupdate
login_host: localhost
login_user: "{{ postgresql.username }}"
login_password: "{{ postgresql.password }}"
query: SELECT id,nameserver_update_secret,public,available,nameserver2_ip FROM main_domain WHERE name = %s
positional_args:
- "{{ nsupdate.basedomain.name }}"
register: select_domain_id_from_db
- name: Update basedomain database entry
postgresql_query:
db: nsupdate
login_host: localhost
login_user: "{{ postgresql.username }}"
login_password: "{{ postgresql.password }}"
query: UPDATE main_domain SET nameserver_update_secret = %s, public = %s, available = %s,nameserver2_ip = %s WHERE id = %s
positional_args:
- "{{ basedomain_secret }}"
- "{{ (nsupdate.basedomain.public | default(false)) | ternary('t','f') }}"
- "{{ (nsupdate.basedomain.available | default(false)) | ternary('t','f') }}"
- "{{ db_secondary_ns_ipv4 }}"
- "{{ select_domain_id_from_db.query_result[0].id }}"
when: select_domain_id_from_db.rowcount == 1 and (
select_domain_id_from_db.query_result[0].nameserver_update_secret != basedomain_secret or
select_domain_id_from_db.query_result[0].nameserver2_ip != db_secondary_ns_ipv4 or
select_domain_id_from_db.query_result[0].public != nsupdate.basedomain.public | default(false) or
select_domain_id_from_db.query_result[0].available != nsupdate.basedomain.available | default(false))
- name: Insert basedomain into database
postgresql_query:
db: nsupdate
login_host: localhost
login_user: "{{ postgresql.username }}"
login_password: "{{ postgresql.password }}"
query: >
INSERT INTO main_domain
(name,nameserver_ip,nameserver_update_secret,nameserver_update_algorithm,public,available,comment,last_update,created,created_by_id,nameserver2_ip)
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW(),NOW(),(SELECT id FROM auth_user WHERE username=%s),%s)
positional_args:
- "{{ nsupdate.basedomain.name }}"
- "{{ ansible_default_ipv4.address }}"
- "{{ basedomain_secret }}"
- "HMAC_SHA512"
- "{{ (nsupdate.basedomain.public | default(false)) | ternary('t','f') }}"
- "{{ (nsupdate.basedomain.available | default(false)) | ternary('t','f') }}"
- "Ansible managed"
- "{{ nsupdate.django_superuser.username }}"
- "{{ db_secondary_ns_ipv4 }}"
when: select_domain_id_from_db.rowcount == 0
- import_tasks: nsupdate_prog.yml
- import_tasks: basedomain.yml
\ No newline at end of file
# install nsupdate.info
# requires postgresql
- name: Install basic dependencies for this role
apt:
pkg: [ 'git', 'python3', 'virtualenv', 'python3-pip' ]
install_recommends: no
- name: Install dependencies for nsupdate.info
apt:
pkg: [ 'rustc', 'python3-wheel', 'python3-dev', 'libssl-dev' ]
install_recommends: no
- name: Clone nsupdate.info git repository
git:
repo: https://github.com/nsupdate-info/nsupdate.info.git
dest: /srv/nsupdate.info
update: yes
tags:
- skip_ansible_lint
- name: Install python dependencies
pip:
virtualenv: "/srv/nsupdate.info/env"
virtualenv_python: "python3"
chdir: "/srv/nsupdate.info"
#requirements: "requirements.d/prod.txt"
requirements: "requirements.d/dev.txt"
register: install_dependencies
- name: Set project to editable
pip:
virtualenv: "/srv/nsupdate.info/env"
virtualenv_python: "python3"
chdir: "/srv/nsupdate.info"
name: "."
editable: yes
when: install_dependencies.changed
- name: Create directory /etc/nsupdate.info
file:
path: /etc/nsupdate.info
state: directory
owner: root
group: www-data
mode: 0770
- name: Create local_settings.py
template:
src: "local_settings.py.j2"
dest: "/etc/nsupdate.info/local_settings.py"
owner: root
group: www-data
mode: 0640
notify: restart uwsgi
- name: Install psycopg2 into virtualenv
pip:
virtualenv: "/srv/nsupdate.info/env"
virtualenv_python: "python3"
chdir: "/srv/nsupdate.info"
name: "psycopg2"
# Django 2:
- name: Install django-createsuperuserwithpassword
pip:
virtualenv: "/srv/nsupdate.info/env"
virtualenv_python: "python3"
chdir: "/srv/nsupdate.info"
name: "django-createsuperuserwithpassword"
- name: Initialize database