Gandi Dynamic DNS Scripts for Openwrt

Description: A couple scripts I wrote to ease updating my gandi.net DNS, as well as properly reconfigure OpenVPN for dual-stack support each time my IPv6 PD changed. Intended to be run as a cronjob but is pretty versatile for other uses.
Posting these up here in case they might be useful for adaptation or reference by other people looking for elegant handling of Gandi's DNS service and IPv6 configuration of OpenVPN and Unbound.
You will definitely need to edit them before use, but they should serve as a pretty good baseline for most purposes. Gandi API code adapted from gandi-dyndns.
Requirements: Python3 on Chaos Calmer, or Python on Attitude Adjustment, and An API key from here. If you're getting SSL cert errors, you may also need to install ca-certificates with opkg.
Download: gandi-dyndns.py (Python3 Version for Chaos Calmer)
gandi-dyndns_2.py (Python2 Version for Attitude Adjustment)
update-openvpn-ipv6.lua (Lua helper script to update OpenVPN config for IPv6 via UCI)
Main Script Source Code [Python 3]:
#!/usr/bin/env python
import json
import xmlrpc.client
import subprocess

## Configuration
api = xmlrpc.client.ServerProxy('https://rpc.gandi.net/xmlrpc/', verbose=False, use_builtin_types=True)
apikey = 'YOUR API KEY HERE'
domain = 'YOUR DOMAIN HERE'
records = ['@', 'vpn']
ttl = 900 # 15 minutes
ip4netname = 'wan'
ip6netname = 'wan6'
# Note: Assignment of IPv6 Addresses/Suffixes to Records is configured below in main()

def main():  
    # Ask Ubus for net interface info, then parse its json output
    netinfo = json.loads(subprocess.check_output("ubus call network.interface dump", shell=True, universal_newlines=True))['interface']
    
    # Store the json objects for our interfaces
    for netif in netinfo:
        if netif['interface'] == ip4netname:
            ip4netif = netif
        elif netif['interface'] == ip6netname:
            ip6netif = netif
    
    # Public IPv4 Address
    ip4addr = ip4netif['ipv4-address'][0]['address']

    # Delegated Prefix w/ CIDR Prefix length
    ip6prefix = ip6netif['ipv6-prefix'][0]['address'] + '/' + str(ip6netif['ipv6-prefix'][0]['mask'])
    
    ## IPv6 Configuration ##
    # Get prefixes for networks
    dmz = getip6prefix('dmz', ip6netif)
    vpn = getip6prefix('vpn0', ip6netif)
    
    # IP6 Addresses; Indicies correspond to records
    ip6addrs = [dmz[0] + 'bad:d06e', vpn[0] + '1']
    
    # Prefix lengths for each record's address
    ip6prefixlens = [dmz[1], vpn[1]]
    ## End IPv6 Configuration ##
    
    # Get the Zone ID for given domain
    active_zone = api.domain.info(apikey, domain)['zone_id']
    
    # State storage setup
    numrecs = len(records)
    ip4changed = [False] * numrecs
    ip6changed = [False] * numrecs
    
    # Check IPs for all records 
    for i in range(0, numrecs):
        # Did v4 Change?
        ip4changed[i] = ip_changed(records[i], 'A', ip4addr, active_zone)
        # Did v6 Change?
        ip6changed[i] = ip_changed(records[i], 'AAAA', ip6addrs[i], active_zone)
    
    # If any IP changed, create a new zone and update necessary records, otherwise we're done and will exit here
    if True in ip4changed + ip6changed:
        # Create new zone to save changes in
        new_zone = api.domain.zone.version.new(apikey, active_zone)
        # Apply changed IPs to new zone
        for i in range(0, numrecs):
            if ip4changed[i]: 
                update_record(records[i], 'A', ip4addr, active_zone, new_zone)
                print("IPv4 Updated for " + records[i])
            if ip6changed[i]:
                update_record(records[i], 'AAAA', ip6addrs[i], active_zone, new_zone)
                print("IPv6 Updated for " + records[i])
                # Use VPN Record as a trigger to update OpenVPN and Unbound configuration
                if records[i] == 'vpn':
                    subprocess.call(['lua', '/opt/scripts/update-openvpn-ipv6.lua', ip6addrs[i] + '/' + ip6prefixlens[i], ip6prefix])
                    subprocess.call(['/etc/init.d/openvpn', 'reload'])
                    print("Updated OpenVPN Configuration.")
                    with open('/etc/unbound/allow-v6prefix.conf', 'w') as f:
                        f.write('access-control: ' + ip6prefix + ' allow')
                    subprocess.call(['/etc/init.d/unbound', 'reload'])
                    print("Updated Unbound Configuration.")
        # Activate the new zone
        api.domain.zone.version.set(apikey, active_zone, new_zone)
    else: 
        print('All records are already up-to-date.')

# Update a record on the server
# Takes a record name, the record type, the new value for the record
# The XMLXPC object for the current zone, and the XMLRPC object for the new zone
def update_record(record, rtype, value, active_zone, new_zone):
    # Delete Record
    records = api.domain.zone.record.delete(apikey, active_zone, new_zone, {"name": record, "type": rtype})
    # Add New Record
    api.domain.zone.record.add(apikey, active_zone, new_zone, {"name": record, "ttl": ttl, "type": rtype, "value": value})

# Check if the IP for given record changed
# Takes a record name, the record type, an ip to check against, and the XMLRPC object for the current zone                         
def ip_changed(record, rtype, ip, active_zone):
    recinfo = api.domain.zone.record.list(apikey, active_zone, 0, {"name": record, "type": rtype})[0]
    return not recinfo['value'] == ip

# Get assigned IPv6 Prefix for Given Interface
# Takes interface name and the json object with interface info
# Returns List with Address as 0, Prefix length as 1
def getip6prefix(interface, ip6netif):
    ifinfo = ip6netif['ipv6-prefix'][0]['assigned'][interface]
    return [ifinfo['address'], str(ifinfo['mask'])]    

if __name__ == "__main__":
    main()

OpenVPN Helper Script [LUA]:
-- Setup
require("uci")
u = uci.cursor(nil, "/var/state")
push_entries = u:get("openvpn", "wuffvpn", "push")
new_push = {}

-- Remove Public IPv6 Routes from Push List
for k,v in pairs(push_entries) do
  if not v:match('^route%-ipv6 ') or v:match('^.+ fd%d+:') then
    table.insert(new_push, push_entries[k])
  end
end

-- Add passed arguments to config (First: Server IPv6, Second: Subnet to Allow)
-- These are expected to be passed in CIDR IP::ADDR/mask notation
u:set("openvpn", "wuffvpn", "server_ipv6", arg[1])
table.insert(new_push, 'route-ipv6 ' .. arg[2])

-- Replace push list and commit
u:set("openvpn", "wuffvpn", "push", new_push)
u:commit("openvpn")

-- Debug: Print table as bracketed list
-- print('{ "' .. table.concat(new_push,'", "') .. '" }')