Let’s Encrypt with Synology NAS when you can’t open port 80

By | November 20, 2016

UPDATE [2022-02-06]: I’m fairly certain nothing in this blog posting is needed anymore with the current version of Synology’s NAS software (DSM). My NAS’s SSL certificate has been renewing automatically for quite a while now without my ever needing to use this script. I don’t know what mechanism DSM is now using to renew Let’s Encrypt certificates, but it appears to be immune to the firewall issues described below.

Synology’s DiskStation software supports the use of Let’s Encrypt to install free, automatically renewing SSL certificates in your NAS. This page explains how to set it up. This is awesome.

The Let’s Encrypt service verifies that you are entitled to an SSL certificate for the host name of your NAS by connecting to port 80 on the NAS and checking for a special web page that the NAS creates temporarily during the certificate installation process.

That’s all well and good if you are able to open up port 80 on your public-facing router to either pass through or forward traffic to your NAS. Unfortunately, some people can’t do that, for various reasons. For example, my ISP (RCN) doesn’t allow inbound connections on port 80 for non-business accounts, so there’s literally no way for me to make port 80 on my public IP address forward traffic to the NAS on my internal network.

Here’s the workaround I came up with to solve this problem:

  1. Spin up a cheap AWS instance running Ubuntu Linux (I used a t2.nano instance, literally the cheapest instance type) with its security group configured to allow connections on ports 22 and 80. (To be clear: you can use any Linux server running anywhere on the internet that you can SSH into as root and that can accept traffic on port 80; I found using a disposable AWS instance to be the simplest solution because it doesn’t require messing with any of my other servers in any way. These instructions assume that you know the basics of working with AWS instances.)
  2. Edit /etc/ssh/sshd_config on the AWS instance to add the line “GatewayPorts clientspecified” and then do “systemctl reload sshd”.
  3. Edit /root/.ssh/authorized_keys on the AWS instance to remove all the restrictions at the beginning of the key so that you are able to ssh into the AWS instance as root.
  4. From a Linux box that has the ability to connect to port 80 on the NAS (confirm by running “curl http://ip-address-of-NAS/” on this box and verify that you get back a “302 Found” response), connect to the AWS instance via SSH with the following command: “ssh -R :80:ip-address-of-NAS:80 root@host-name-of-AWS-instance“. Keep the SSH connection open.
  5. Run “curl http://host-name-of-AWS-instance/” from your local Linux box (not from the AWS instance) and confirm that you get back the “302 Found” response again. As long as you keep open the SSH connection created in step 4, your AWS instance will forward port 80 connections to your NAS.
  6. Temporarily edit your DNS so the host name of your NAS is a CNAME for the host name of the AWS instance. You may need to wait a few minutes for this change to propagate. Make sure you remember what it was set to before, so that you can put it back afterward!
  7. Now go through the process on the DiskStation for adding a Let’s Encrypt certificate to your NAS. When the Let’s Encrypt service goes to connect to port 80 on your NAS to verify your ownership of the host name, it will be connecting to the AWS instance instead, and the SSH tunnel you created in step 4 will route the connection to the NAS.
  8. Once the certificate is successfully installed, change the DNS record for your NAS back to its previous value.
  9. Don’t forget to terminate the AWS instance when you’re finished!

I’m pretty certain — though I’ll let you know in three months when my Let’s Encrypt certificate expires! — that port 80 to the NAS is only required to be open when first creating the certificate, not when it is renewed automatically by the NAS later.

UPDATE: In fact, port 80 to the NAS needs to be open when the NAS tries to renew your certificate. This means when you get email from Let’s Encrypt telling you that your certificate is going to expire, you need to follow the instructions above again, but in step 7, SSH into your NAS as root and run the command “syno-letsencrypt renew-all -v“. It will tell you if the certificate was successfully renewed.

Comment below if this was helpful!

P.S. On the off chance that there’s somebody else with this problem who uses Namecheap as their DNS provider like I do, here’s the script I wrote to (mostly) automate this:

#!/usr/bin/env python

"""Synology NAS / Let's Encrypt / Namecheap / Port 80 blocked script

This script automates (mostly) the process of renewing the Let's
Encrypt SSL certificate(s) on your Synology NAS, if you use Namecheap
as your DNS provider, when the script can't be renewed by the NAS
automatically because port 80 is blocked by your ISP.

You need the following:

* a relay host somewhere outside your home network:
  * that can accept connections on port 80 and the other ports your
    NAS uses and doesn't have other things listening on those ports;
  * that you can SSH into as root; and
  * that uses ufw as its firewall.
* a host on the same network as your NAS from which to run this
  script;
* the ability to SSH into your NAS as root;
* the API is enabled in your Namecheap account and you have the API
  key.

To use this script, create a file ~/closed/nas-renew-ssl.yml (or
change the "config_file" variable below to point at a different file
name) which looks like this:

Namecheap:
  ApiUser: your namecheap username
  UserName: your namecheap username
  ApiKey: your namecheap API key
  ClientIP: the public IP address of the machine you're running the
    script on
  SLD: your subdomain name (e.g., for "smith.com" it would be "smith")
  TLD: your top-level domain (e.g., "com")
Relay:
  Host: the host name of your relay host
  Ports:
  - 80
  - another port on the NAS that you use
  - another port on the NAS that you use
  - etc.
NAS:
  DNSHost: the name of your NAS in Namecheap's DNS records
  ForwardHost: the name or IP address of your NAS accessible from
    where you are running this script

After setting up the config file as described above and running the
script, it will do the following:

* Use SSH to set up port forwarding through the relay host to your
  NAS.
* Confirm that port forwarding is working.
* Change your Namecheap DNS to redirect traffic to it through your
  relay host.
* SSH into your NAS and tell it to renew the certificates.
* Undo the traffic redirection through the relay host.
* Turn off the port forwarding.

Note: If you have any dynamic DNS records, after this script runs they
will no longer show up as dynamic DNS records in the web UI, but
dynamic DNS clients will still be able to update them.

Email Jonathan Kamens <jik@kamens.us> with any questions.

Share and enjoy!
"""

from copy import deepcopy
import dns.resolver
from itertools import count, izip
from lxml import etree
import os
import requests
import subprocess
import time
import yaml


config_file = os.path.expanduser('~/closed/nas-renew-ssl.yml')
namecheap_api_url = 'https://api.namecheap.com/xml.response'


def get_config_yaml(filename):
    return yaml.load(open(filename))


def make_namecheap_request(config, data):
    request = data.copy()
    request.update({
        'ApiUser': config['Namecheap']['ApiUser'],
        'UserName': config['Namecheap']['UserName'],
        'ApiKey': config['Namecheap']['ApiKey'],
        'ClientIP': config['Namecheap']['ClientIP'],
        'SLD': config['Namecheap']['SLD'],
        'TLD': config['Namecheap']['TLD'],
    })
    response = requests.post(namecheap_api_url, request)
    response.raise_for_status()
    response = etree.XML(response.content)
    assert response.get('Status') == 'OK'
    return response


def get_hosts(config):
    data = {'Command': 'namecheap.domains.dns.getHosts'}
    response = make_namecheap_request(config, data)
    host_elements = response.xpath(
        '/x:ApiResponse/x:CommandResponse/x:DomainDNSGetHostsResult/x:host',
        namespaces={'x': 'http://api.namecheap.com/xml.response'})
    return [h.attrib for h in host_elements]


def set_hosts(config, hosts):
    data = {'Command': 'namecheap.domains.dns.setHosts'}
    for num, host in izip(count(1), hosts):
        data['Hostname{}'.format(num)] = host['Name']
        data['RecordType{}'.format(num)] = host['Type']
        data['Address{}'.format(num)] = host['Address']
        if host['Type'] == 'MX':
            data['MXPref{}'.format(num)] = host['MXPref']
    make_namecheap_request(config, data)


def start_port_forwarding(nas_host, relay_host, ports_to_forward):
    cmd = ('ssh root@{} "echo '.format(relay_host) +
           'GatewayPorts clientspecified >> ' +
           '/etc/ssh/sshd_config && service ssh reload"')
    subprocess.check_call(cmd, shell=True)
    cmd = ('ssh -M -S nas-ctrl-socket -fnNT ' +
           ' '.join('-R :{}:{}:{}'.format(p, nas_host, p)
                    for p in ports_to_forward) +
           ' root@{}'.format(relay_host))
    subprocess.check_call(cmd, shell=True)
    cmd = 'ssh -S nas-ctrl-socket -O check root@{}'.format(relay_host)
    subprocess.check_call(cmd, shell=True)
    cmd = ('ssh -S nas-ctrl-socket root@{} "'.format(relay_host) +
           ' && '.join('ufw allow {}'.format(p) for p in ports_to_forward) +
           ' "')
    subprocess.check_call(cmd, shell=True)


def confirm_port_forwarding(relay_host, fail_fast=False):
    # Assumes port 80 is being forwarded
    url = 'http://{}/'.format(relay_host)
    try:
        response = requests.get(url, timeout=1, allow_redirects=False)
    except:
        if fail_fast:
            raise
        time.sleep(1)
        response = requests.get(url, timeout=1, allow_redirects=False)
    response.raise_for_status()


def stop_port_forwarding(relay_host, ports_to_forward):
    cmd = ('ssh -S nas-ctrl-socket root@{} "sed -i '.format(relay_host) +
           '-e /\^GatewayPorts/d ' +
           '/etc/ssh/sshd_config && service ssh reload"')
    subprocess.check_call(cmd, shell=True)
    cmd = ('ssh -S nas-ctrl-socket root@{} "'.format(relay_host) +
           ' && '.join('ufw delete allow {}'.format(p)
                       for p in ports_to_forward) +
           '"')
    subprocess.check_call(cmd, shell=True)
    cmd = 'ssh -S nas-ctrl-socket -O exit root@{}'.format(relay_host)
    subprocess.check_call(cmd, shell=True)


def redirect_nas(config, hosts, nas_host, relay_host):
    hosts = deepcopy(hosts)
    nas_host_object = next(h for h in hosts if h['Name'] == nas_host)
    assert nas_host_object['Type'] == 'CNAME'
    nas_host_object['Address'] = relay_host
    nas_host_object['TTL'] = 60
    set_hosts(config, hosts)


def confirm_redirect_nas(config, nas_host, relay_host):
    # Wait up to 10 seconds for change to propagate within Namecheap.
    domain = config['Namecheap']['SLD'] + '.' + config['Namecheap']['TLD']
    nas_host = nas_host + '.' + domain
    relay_host = relay_host.lower()
    if not relay_host.endswith('.'):
        relay_host += '.'

    dns_wait_time = 10
    end = time.time() + dns_wait_time

    nameservers = dns.resolver.query(domain, 'NS')
    if len(nameservers) == 0:
        raise Exception('Failed to identify nameservers for {}'.format(domain))

    for nameserver in nameservers:
        print("Checking {}".format(nameserver))
        address = str(dns.resolver.query(str(nameserver), 'A')[0])
        while True:
            my_resolver = dns.resolver.Resolver()
            my_resolver.nameservers = [address]
            result = my_resolver.query(nas_host, 'CNAME')
            if len(result) > 0 and str(result[0]) == relay_host:
                print("Success")
                break
            if time.time() > end:
                raise Exception('DNS changes failed to propagate after {} '
                                'seconds'.format(dns_wait_time))
            print "Sleeping"
            time.sleep(1)


def renew_certs(nas_host):
    cmd = 'ssh root@{} /usr/syno/sbin/syno-letsencrypt renew-all -v'.format(
        nas_host)
    subprocess.check_call(cmd, shell=True)


config = get_config_yaml(config_file)
relay_host = config['Relay']['Host']
ports_to_forward = config['Relay']['Ports']
nas_dns_host = config['NAS']['DNSHost']
nas_forward_host = config['NAS']['ForwardHost']


start_port_forwarding(nas_forward_host, relay_host, ports_to_forward)
confirm_port_forwarding(relay_host)
hosts = get_hosts(config)
redirect_nas(config, hosts, nas_dns_host, relay_host)
confirm_redirect_nas(config, nas_dns_host, relay_host)

try:
    renew_certs(nas_forward_host)
finally:
    set_hosts(config, hosts)

    stop_port_forwarding(relay_host, ports_to_forward)
    try:
        confirm_port_forwarding(relay_host, fail_fast=True)
    except requests.exceptions.ConnectTimeout:
        pass
    else:
        raise Exception("Ports are still forwarding!")
Share

8 thoughts on “Let’s Encrypt with Synology NAS when you can’t open port 80

  1. Richard

    Hi there,

    Thanks for the information, however I’m confused on doing curl from the internal Linux box in which enable to connect to port 80? My Synology is behind my routers which usually will have its own local IP. then what needs to be done? Should I just try using the public IP? Hope to get some response. Again thanks for the idea…

    Reply
    1. jik Post author

      I don’t understand your question so I don’t know how to answer it. Sorry.

      Reply
      1. Richard

        Nevermid. I got it working.. thanks again for the guide. Got a lil bit lost in making the PORT 80 open for synology. But finally I got it working. I suppose to run `ssh -R :80:ip-address-of-NAS:80 root@host-name-of-AWS-instance` on my local terminal. Again thanks for the guide. Now I need to wait for 3 months and complet your guide for a renewals.

        Reply
  2. Andy

    Wow! That was very clever. My ISP filters inbound traffic to port 80 too. They claim it is for my safety, but if I pay for a static IP I guess I don’t need so much nannying.

    I’m afraid that I’m not clever enough to spin up AWS instances – or work Linux for that matter. It would be nice if LetsEncypt allowed for a port redirection. Why hard-code it?

    Anyway, I just wanted to thank you for your post. I understood what you were doing, but unfortunately there are too many gaps for my feeble mind to fill. I’m sure it will help others though.

    Reply
  3. koziolek

    You could also use dns-01 challenge which requires setting TXT record for _acme-challenge.your.domain.com – usefull when your NAS is not accessible from the Internet or you want to use (sub)domain that resolves to 192.168.x.x

    Reply
    1. jik Post author

      Synology doesn’t support using a DNS challenge. That’s why this technique is necessary.

      Reply
    1. jik Post author

      That’s clever.

      I’m a little uncomfortable about security-related traffic passing through somebody else’s server over which I have no control, though. The risk here is minimal, but not zero.

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *