From fba8f5d77650f1345cec934531a760298207f661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Wed, 3 Jan 2018 22:12:02 +0000 Subject: [PATCH] Integrate LEDE image builder --- certidude/api/__init__.py | 9 ++- certidude/api/builder.py | 52 +++++++++++++ certidude/api/script.py | 6 +- certidude/cli.py | 77 +++++++++++-------- certidude/config.py | 9 ++- certidude/const.py | 3 +- certidude/firewall.py | 15 +++- certidude/static/views/attributes.html | 2 +- certidude/static/views/authority.html | 12 ++- certidude/static/views/signed.html | 47 ++++++----- certidude/static/views/tags.html | 2 +- certidude/templates/server/builder.conf | 31 ++++++++ certidude/templates/server/server.conf | 6 ++ doc/build-ap.sh | 58 ++++++++++++++ .../etc/hotplug.d/iface}/50-certidude | 0 doc/overlay/etc/profile | 32 ++++++++ doc/overlay/etc/uci-defaults/40-hostname | 21 +++++ doc/overlay/etc/uci-defaults/50-access-point | 66 ++++++++++++++++ 18 files changed, 386 insertions(+), 62 deletions(-) create mode 100644 certidude/api/builder.py create mode 100644 certidude/templates/server/builder.conf create mode 100644 doc/build-ap.sh rename doc/{ => overlay/etc/hotplug.d/iface}/50-certidude (100%) create mode 100644 doc/overlay/etc/profile create mode 100644 doc/overlay/etc/uci-defaults/40-hostname create mode 100644 doc/overlay/etc/uci-defaults/50-access-point diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 9e1fc83..5a8c212 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -82,7 +82,7 @@ class SessionResource(object): attributes = {} for key in listxattr(path): if key.startswith(b"user.machine."): - attributes[key[13:]] = getxattr(path, key).decode("ascii") + attributes[key[13:].decode("ascii")] = getxattr(path, key).decode("ascii") # Extract lease information from filesystem try: @@ -131,6 +131,9 @@ class SessionResource(object): ), request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED, authority = dict( + builder = dict( + profiles = config.IMAGE_BUILDER_PROFILES + ), tagging = [dict(name=t[0], type=t[1], title=t[2]) for t in config.TAG_TYPES], lease = dict( offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option @@ -208,6 +211,7 @@ def certidude_app(log_handlers=[]): from .attrib import AttributeResource from .bootstrap import BootstrapResource from .token import TokenResource + from .builder import ImageBuilderResource app = falcon.API(middleware=NormalizeMiddleware()) app.req_options.auto_parse_form_urlencoded = True @@ -240,6 +244,9 @@ def certidude_app(log_handlers=[]): # Bootstrap resource app.add_route("/api/bootstrap/", BootstrapResource()) + # LEDE image builder resource + app.add_route("/api/build/{profile}/{suggested_filename}", ImageBuilderResource()) + # Add CRL handler if we have any whitelisted subnets if config.CRL_SUBNETS: from .revoked import RevocationListResource diff --git a/certidude/api/builder.py b/certidude/api/builder.py new file mode 100644 index 0000000..61538a4 --- /dev/null +++ b/certidude/api/builder.py @@ -0,0 +1,52 @@ + +import click +import falcon +import logging +import os +import subprocess +from certidude import config, const +from certidude.auth import login_required, authorize_admin +from jinja2 import Template + +logger = logging.getLogger(__name__) + +class ImageBuilderResource(object): + @login_required + @authorize_admin + def on_get(self, req, resp, profile, suggested_filename): + model = config.cp2.get(profile, "model") + build_script_path = config.cp2.get(profile, "command") + overlay_path = config.cp2.get(profile, "overlay") + site_script_path = config.cp2.get(profile, "script") + suffix = config.cp2.get(profile, "filename") + + build = "/var/lib/certidude/builder/" + profile + if not os.path.exists(build + "/overlay/etc/uci-defaults"): + os.makedirs(build + "/overlay/etc/uci-defaults") + os.system("rsync -av " + overlay_path + "/ " + build + "/overlay/") + + if site_script_path: + template = Template(open(site_script_path).read()) + with open(build + "/overlay/etc/uci-defaults/99-site-config", "w") as fh: + fh.write(template.render(authority_name=const.FQDN)) + + proc = subprocess.Popen(("/bin/bash", build_script_path), + stdout=open(build + "/build.log", "w"), stderr=subprocess.STDOUT, + close_fds=True, shell=False, + cwd=build, + env={"PROFILE":model, "PATH":"/usr/sbin:/usr/bin:/sbin:/bin"}, + startupinfo=None, creationflags=0) + proc.communicate() + + for dname in os.listdir(build): + if dname.startswith("lede-imagebuilder-"): + for root, dirs, files in os.walk(os.path.join(build, dname, "bin", "targets")): + for filename in files: + if filename.endswith(suffix): + path = os.path.join(root, filename) + click.echo("Serving: %s" % path) + resp.body = open(path, "rb").read() + resp.set_header("Content-Disposition", ("attachment; filename=%s" % suggested_filename)) + return + raise falcon.HTTPNotFound() + diff --git a/certidude/api/script.py b/certidude/api/script.py index 889519b..494e528 100644 --- a/certidude/api/script.py +++ b/certidude/api/script.py @@ -1,5 +1,6 @@ import falcon import logging +import os from certidude import const, config, authority from certidude.decorators import serialize from jinja2 import Environment, FileSystemLoader @@ -26,9 +27,10 @@ class ScriptResource(): except AttributeError: # No tags pass - script = named_tags.get("script", config.SCRIPT_DEFAULT) + script = named_tags.get("script", "default.sh") + assert script in os.listdir(config.SCRIPT_DIR) resp.set_header("Content-Type", "text/x-shellscript") - resp.body = env.get_template(script).render( + resp.body = env.get_template(os.path.join(script)).render( authority_name=const.FQDN, common_name=cn, other_tags=other_tags, diff --git a/certidude/cli.py b/certidude/cli.py index 5b57495..ea31874 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -96,7 +96,7 @@ def setup_client(prefix="client_", dh=False): @click.option("-s", "--skip-self", default=False, is_flag=True, help="Skip self enroll") @click.option("-nw", "--no-wait", default=False, is_flag=True, help="Return immideately if server doesn't autosign") def certidude_enroll(fork, renew, no_wait, kerberos, skip_self): - if not skip_self and os.path.exists(const.CONFIG_PATH): + if not skip_self and os.path.exists(const.SERVER_CONFIG_PATH): click.echo("Self-enrolling authority's web interface certificate") from certidude import authority authority.self_enroll() @@ -944,38 +944,42 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **pat @click.option("--directory", help="Directory for authority files") @click.option("--server-flags", is_flag=True, help="Add TLS Server and IKE Intermediate extended key usage flags") @click.option("--outbox", default="smtp://smtp.%s" % const.DOMAIN, help="SMTP server, smtp://smtp.%s by default" % const.DOMAIN) +@click.option("--skip-packages", is_flag=True, help="Don't attempt to install apt/pip/npm packages") @fqdn_required -def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags, title): +def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, state, locality, organization, organizational_unit, common_name, directory, authority_lifetime, push_server, outbox, server_flags, title, skip_packages): assert os.getuid() == 0 and os.getgid() == 0, "Authority can be set up only by root" import pwd from jinja2 import Environment, PackageLoader env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True) - click.echo("Installing packages...") - os.system("apt-get install -qq -y cython3 python3-dev python3-mimeparse \ - python3-markdown python3-pyxattr python3-jinja2 python3-cffi \ - software-properties-common libsasl2-modules-gssapi-mit npm nodejs \ - libkrb5-dev libldap2-dev libsasl2-dev") - os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam") - os.system("pip3 install -q --pre --upgrade python-ldap") - - if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"): - click.echo("Enabling nginx PPA") - os.system("add-apt-repository -y ppa:nginx/stable") - os.system("apt-get update -q") - os.system("apt-get install -y -q libnginx-mod-nchan") + if skip_packages: + click.echo("Not attempting to install packages from APT as requested...") else: - click.echo("PPA for nginx already enabled") + click.echo("Installing packages...") + os.system("apt-get install -qq -y cython3 python3-dev python3-mimeparse \ + python3-markdown python3-pyxattr python3-jinja2 python3-cffi \ + software-properties-common libsasl2-modules-gssapi-mit npm nodejs \ + libkrb5-dev libldap2-dev libsasl2-dev gawk libncurses5-dev") + os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam") + os.system("pip3 install -q --pre --upgrade python-ldap") - if not os.path.exists("/usr/sbin/nginx"): - click.echo("Installing nginx from PPA") - os.system("apt-get install -y -q nginx") - else: - click.echo("Web server nginx already installed") + if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"): + click.echo("Enabling nginx PPA") + os.system("add-apt-repository -y ppa:nginx/stable") + os.system("apt-get update -q") + os.system("apt-get install -y -q libnginx-mod-nchan") + else: + click.echo("PPA for nginx already enabled") - if not os.path.exists("/usr/bin/node"): - os.symlink("/usr/bin/nodejs", "/usr/bin/node") + if not os.path.exists("/usr/sbin/nginx"): + click.echo("Installing nginx from PPA") + os.system("apt-get install -y -q nginx") + else: + click.echo("Web server nginx already installed") + + if not os.path.exists("/usr/bin/node"): + os.symlink("/usr/bin/nodejs", "/usr/bin/node") # Generate secret for tokens token_secret = ''.join(random.choice(string.ascii_letters + string.digits + '!@#$%^&*()') for i in range(50)) @@ -1036,6 +1040,9 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, else: click.echo("Warning: /etc/krb5.keytab or /etc/samba/smb.conf not found, Kerberos unconfigured") + doc_path = os.path.join(os.path.realpath(os.path.dirname(os.path.dirname(__file__))), "doc") + script_dir = os.path.join(os.path.realpath(os.path.dirname(__file__)), "templates", "script") + static_path = os.path.join(os.path.realpath(os.path.dirname(__file__)), "static") certidude_path = sys.argv[0] @@ -1057,6 +1064,13 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, else: click.echo("Not systemd based OS, don't know how to set up initscripts") + if os.path.exists("/etc/certidude/builder.conf"): + click.echo("Image builder config /etc/certidude/builder.conf already exists, remove to regenerate") + else: + with open("/etc/certidude/builder.conf", "w") as fh: + fh.write(env.get_template("server/builder.conf").render(vars())) + click.echo("File /etc/certidude/builder.conf created") + assert os.getuid() == 0 and os.getgid() == 0 bootstrap_pid = os.fork() if not bootstrap_pid: @@ -1069,7 +1083,10 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, os.makedirs(subdir) # Install JavaScript pacakges - os.system("npm install --silent -g nunjucks@2.5.2 nunjucks-date@1.2.0 node-forge bootstrap@4.0.0-alpha.6 jquery timeago tether font-awesome qrcode-svg") + if skip_packages: + click.echo("Not attempting to install packages from NPM as requested...") + else: + os.system("npm install --silent -g nunjucks@2.5.2 nunjucks-date@1.2.0 node-forge bootstrap@4.0.0-alpha.6 jquery timeago tether font-awesome qrcode-svg") # Compile nunjucks templates cmd = 'nunjucks-precompile --include ".html$" --include ".svg" %s > %s.part' % (static_path, bundle_js) @@ -1109,14 +1126,14 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, if not os.path.exists(const.CONFIG_DIR): click.echo("Creating %s" % const.CONFIG_DIR) os.makedirs(const.CONFIG_DIR) - if os.path.exists(const.CONFIG_PATH): - click.echo("Configuration file %s already exists, remove to regenerate" % const.CONFIG_PATH) + if os.path.exists(const.SERVER_CONFIG_PATH): + click.echo("Configuration file %s already exists, remove to regenerate" % const.SERVER_CONFIG_PATH) else: os.umask(0o137) push_token = "".join([random.choice(string.ascii_letters + string.digits) for j in range(0,32)]) - with open(const.CONFIG_PATH, "w") as fh: + with open(const.SERVER_CONFIG_PATH, "w") as fh: fh.write(env.get_template("server/server.conf").render(vars())) - click.echo("Generated %s" % const.CONFIG_PATH) + click.echo("Generated %s" % const.SERVER_CONFIG_PATH) # Create directory with 755 permissions os.umask(0o022) @@ -1183,7 +1200,7 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, country, from certidude import authority authority.self_enroll() assert os.getuid() == 0 and os.getgid() == 0, "Enroll contaminated environment" - click.echo("To enable e-mail notifications install Postfix as sattelite system and set mailer address in %s" % const.CONFIG_PATH) + click.echo("To enable e-mail notifications install Postfix as sattelite system and set mailer address in %s" % const.SERVER_CONFIG_PATH) click.echo() click.echo("Use following commands to inspect the newly created files:") click.echo() @@ -1337,7 +1354,7 @@ def certidude_serve(port, listen, fork): if port == 80: click.echo("WARNING: Please run Certidude behind nginx, remote address is assumed to be forwarded by nginx!") - click.echo("Using configuration from: %s" % const.CONFIG_PATH) + click.echo("Using configuration from: %s" % const.SERVER_CONFIG_PATH) log_handlers = [] diff --git a/certidude/config.py b/certidude/config.py index d9dab0f..8c98605 100644 --- a/certidude/config.py +++ b/certidude/config.py @@ -12,7 +12,7 @@ from random import choice # Options that are parsed from config file are fetched here cp = configparser.RawConfigParser() -cp.readfp(codecs.open(const.CONFIG_PATH, "r", "utf8")) +cp.readfp(open(const.SERVER_CONFIG_PATH, "r")) AUTHENTICATION_BACKENDS = set([j for j in cp.get("authentication", "backends").split(" ") if j]) # kerberos, pam, ldap @@ -99,7 +99,10 @@ TOKEN_SECRET = cp.get("token", "secret").encode("ascii") # TODO: Check if we don't have base or servers # The API call for looking up scripts uses following directory as root -SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "templates", "script") -SCRIPT_DEFAULT = "default.sh" +SCRIPT_DIR = cp.get("script", "path") PROFILES = OrderedDict([[i, [j.strip() for j in cp.get("profile", i).split(",")]] for i in cp.options("profile")]) + +cp2 = configparser.RawConfigParser() +cp2.readfp(open(const.BUILDER_CONFIG_PATH, "r")) +IMAGE_BUILDER_PROFILES = [(j, cp2.get(j, "title"), cp2.get(j, "rename")) for j in cp2.sections()] diff --git a/certidude/const.py b/certidude/const.py index 5f36510..2e406e9 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -7,7 +7,8 @@ import sys KEY_SIZE = 1024 if os.getenv("TRAVIS") else 4096 RUN_DIR = "/run/certidude" CONFIG_DIR = "/etc/certidude" -CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf") +SERVER_CONFIG_PATH = os.path.join(CONFIG_DIR, "server.conf") +BUILDER_CONFIG_PATH = os.path.join(CONFIG_DIR, "builder.conf") CLIENT_CONFIG_PATH = os.path.join(CONFIG_DIR, "client.conf") SERVICES_CONFIG_PATH = os.path.join(CONFIG_DIR, "services.conf") SERVER_PID_PATH = os.path.join(RUN_DIR, "server.pid") diff --git a/certidude/firewall.py b/certidude/firewall.py index 47ac4a1..6a16d49 100644 --- a/certidude/firewall.py +++ b/certidude/firewall.py @@ -1,6 +1,8 @@ import falcon import logging +import click +from asn1crypto import pem, x509 logger = logging.getLogger("api") @@ -50,6 +52,17 @@ def whitelist_subject(func): except IOError: raise falcon.HTTPNotFound() else: + # First attempt to authenticate client with certificate + buf = req.get_header("X-SSL-CERT") + if buf: + header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii")) + origin_cert = x509.Certificate.load(der_bytes) + if origin_cert.native == cert.native: + click.echo("Subject authenticated using certificates") + return func(self, req, resp, cn, *args, **kwargs) + + # For backwards compatibility check source IP address + # TODO: make it disableable try: inner_address = getxattr(path, "user.lease.inner_address").decode("ascii") except IOError: @@ -58,6 +71,6 @@ def whitelist_subject(func): if req.context.get("remote_addr") != ip_address(inner_address): raise falcon.HTTPForbidden("Forbidden", "Remote address %s mismatch" % req.context.get("remote_addr")) else: - return func(self, req, resp, cn, *args, **kwargs) + return func(self, req, resp, cn, *args, **kwargs) return wrapped diff --git a/certidude/static/views/attributes.html b/certidude/static/views/attributes.html index 9423b3f..deb7fe4 100644 --- a/certidude/static/views/attributes.html +++ b/certidude/static/views/attributes.html @@ -1,3 +1,3 @@ {% for key, value in certificate.attributes %} -{{ value }} +{{ value }} {% endfor %} diff --git a/certidude/static/views/authority.html b/certidude/static/views/authority.html index 058efd7..8e9a223 100644 --- a/certidude/static/views/authority.html +++ b/certidude/static/views/authority.html @@ -28,7 +28,7 @@ curl -f -L -H "Content-type: application/pkcs10" --data-binary @client_req.pem \ http://{{ window.location.hostname }}/api/request/?wait=yes > client_cert.pem -
OpenWrt/LEDE
+
Vanilla OpenWrt/LEDE

On OpenWrt/LEDE router to convert it into VPN gateway:

@@ -45,6 +45,16 @@ curl -f -L -H "Content-type: application/pkcs10" \ http://{{ window.location.hostname }}/api/request/?wait=yes
+ {% if session.authority.builder %} +
OpenWrt/LEDE image builder
+

Hit a link to generate machine specific image. Note that this might take couple minutes to finish.

+ + {% endif %} +
SCEP

Use following as the enrollment URL: http://{{ window.location.hostname }}/cgi-bin/pkiclient.exe

diff --git a/certidude/static/views/signed.html b/certidude/static/views/signed.html index 5c9c12c..29ef52b 100644 --- a/certidude/static/views/signed.html +++ b/certidude/static/views/signed.html @@ -24,11 +24,17 @@ Part of {{ certificate.organizational_unit }} organizational unit. {% endif %}

+

{% if session.authority.tagging %} -

+ {% include "views/tags.html" %} -

+ {% endif %} + + {% include "views/attributes.html" %} + +

+
-
-

-

- {% if session.authority.tagging %} - - - - {% endif %} -
-

+
+ {% if session.authority.tagging %} + + + + {% endif %} +
+ +

To fetch certificate:

@@ -77,7 +82,7 @@ curl http://{{ window.location.hostname }}/api/signed/{{ certificate.common_name
curl http://{{ window.location.hostname }}/api/certificate/ > session.pem
 openssl ocsp -issuer session.pem -CAfile session.pem \
   -url http://{{ window.location.hostname }}/api/ocsp/ \
-  -serial 0x{{ certificate.serial }}
+ -serial 0x{{ certificate.serial }}

To fetch script:

cd /var/lib/certidude/{{ window.location.hostname }}/
diff --git a/certidude/static/views/tags.html b/certidude/static/views/tags.html
index a1f54ef..247181a 100644
--- a/certidude/static/views/tags.html
+++ b/certidude/static/views/tags.html
@@ -2,5 +2,5 @@
   {{ tag.value }}
+    onClick="onTagClicked(this);"> {{ tag.value }}
 {% endfor %}
diff --git a/certidude/templates/server/builder.conf b/certidude/templates/server/builder.conf
new file mode 100644
index 0000000..58008b6
--- /dev/null
+++ b/certidude/templates/server/builder.conf
@@ -0,0 +1,31 @@
+[tpl-archer-c7]
+# Title shown in the UI
+title = TP-Link Archer C7 (Access Point)
+
+# Script to build the image, copy file to /etc/certidude/ and make modifications as necessary
+command = {{ doc_path }}/build-ap.sh
+
+# Path to filesystem overlay, used
+overlay = {{ doc_path }}/overlay
+
+# Site specific script to be copied to /etc/uci-defaults/99-site-script
+script =
+
+# Device/model/profile selection
+model = archer-c7-v2
+
+# File that will be picked from the bin/ folder
+filename = archer-c7-v2-squashfs-factory-eu.bin
+
+# And renamed to make it TFTP-friendly
+rename = ArcherC7v2_tp_recovery.bin
+
+[cf-e380ac]
+title = Comfast E380AC (Access Point)
+command = {{ doc_path }}/build-ap.sh
+overlay = {{ doc_path }}/overlay
+script =
+model = cf-e380ac-v2
+filename = cf-e380ac-v2-squashfs-factory.bin
+rename = firmware_auto.bin
+
diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf
index 5e5e106..f11efad 100644
--- a/certidude/templates/server/server.conf
+++ b/certidude/templates/server/server.conf
@@ -198,3 +198,9 @@ default = client, 120,
 srv = server, 365, Server
 gw = server, 3, Gateway
 ap = client, 1825, Access Point
+
+[script]
+# Path to the folder with scripts that can be served to the clients, set none to disable scripting
+path = {{ script_dir }}
+;path = /etc/certidude/script
+;path =
diff --git a/doc/build-ap.sh b/doc/build-ap.sh
new file mode 100644
index 0000000..d18c320
--- /dev/null
+++ b/doc/build-ap.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+
+set -e
+set -x
+umask 022
+
+VERSION=17.01.4
+BASENAME=lede-imagebuilder-$VERSION-ar71xx-generic.Linux-x86_64
+FILENAME=$BASENAME.tar.xz
+URL=http://downloads.lede-project.org/releases/$VERSION/targets/ar71xx/generic/$FILENAME
+
+PACKAGES="luci luci-app-commands \
+    collectd collectd-mod-conntrack collectd-mod-interface \
+    collectd-mod-iwinfo collectd-mod-load collectd-mod-memory \
+    collectd-mod-network collectd-mod-protocols collectd-mod-tcpconns \
+    collectd-mod-uptime \
+    openssl-util openvpn-openssl curl ca-certificates \
+    htop iftop tcpdump nmap nano -odhcp6c -odhcpd -dnsmasq \
+    -luci-app-firewall \
+    -pppd -luci-proto-ppp -kmod-ppp -ppp -ppp-mod-pppoe \
+    -kmod-ip6tables -ip6tables -luci-proto-ipv6 -kmod-iptunnel6 -kmod-ipsec6"
+
+
+if [ ! -e $FILENAME ]; then
+    wget -q $URL
+fi
+
+if [ ! -e $BASENAME ]; then
+    tar xf $FILENAME
+fi
+
+cd $BASENAME
+
+# Copy CA certificate
+AUTHORITY=$(hostname -f)
+CERTIDUDE_DIR=/var/lib/certidude/$AUTHORITY
+if [ -d "$CERTIDUDE_DIR" ]; then
+    mkdir -p overlay/$CERTIDUDE_DIR
+    cp $CERTIDUDE_DIR/ca_cert.pem overlay/$CERTIDUDE_DIR
+fi
+
+cat < EOF > overlay/etc/config/certidude
+
+config authority
+    option url http://$AUTHORITY
+    option authority_path /var/lib/certidude/$AUTHORITY/ca_cert.pem
+    option request_path /var/lib/certidude/$AUTHORITY/client_req.pem
+    option certificate_path /var/lib/certidude/$AUTHORITY/client_cert.pem
+    option key_path /var/lib/certidude/$AUTHORITY/client_key.pem
+    option key_type rsa
+    option key_length 1024
+    option red_led gl-connect:red:wlan
+    option green_led gl-connect:green:lan
+
+EOF
+
+make image FILES=../overlay/ PACKAGES="$PACKAGES" PROFILE="$PROFILE"
+
diff --git a/doc/50-certidude b/doc/overlay/etc/hotplug.d/iface/50-certidude
similarity index 100%
rename from doc/50-certidude
rename to doc/overlay/etc/hotplug.d/iface/50-certidude
diff --git a/doc/overlay/etc/profile b/doc/overlay/etc/profile
new file mode 100644
index 0000000..7203174
--- /dev/null
+++ b/doc/overlay/etc/profile
@@ -0,0 +1,32 @@
+#!/bin/sh
+[ -f /etc/banner ] && cat /etc/banner
+[ -e /tmp/.failsafe ] && cat /etc/banner.failsafe
+
+export PATH=/usr/bin:/usr/sbin:/bin:/sbin
+export HOME=$(grep -e "^${USER:-root}:" /etc/passwd | cut -d ":" -f 6)
+export HOME=${HOME:-/root}
+export PS1='\u@\h:\w\$ '
+
+[ -z "$KSH_VERSION" -o \! -s /etc/mkshrc ] || . /etc/mkshrc
+[ -x /bin/more ] || alias more=less
+[ -x /usr/bin/vim ] && alias vi=vim || alias vim=vi
+[ -x /usr/bin/arp ] || arp() { cat /proc/net/arp; }
+[ -x /usr/bin/ldd ] || ldd() { LD_TRACE_LOADED_OBJECTS=1 $*; }
+
+HOSTNAME=$(uci get system.@system[0].hostname)
+DOMAIN=$(uci -q get dhcp.@dnsmasq[0].domain)
+
+if [ $? -eq 0 ]; then
+    FQDN=$HOSTNAME.$DOMAIN
+else
+    FQDN=$HOSTNAME
+fi
+
+export PS1='\[\033[01;31m\]$FQDN\[\033[01;34m\] \W #\[\033[00m\] '
+case "$TERM" in
+    xterm*|rxvt*)
+        echo -ne "\033]0;${USER}@${FQDN}:${PWD}\007"
+    ;;
+    *)
+    ;;
+esac
diff --git a/doc/overlay/etc/uci-defaults/40-hostname b/doc/overlay/etc/uci-defaults/40-hostname
new file mode 100644
index 0000000..71fa2fa
--- /dev/null
+++ b/doc/overlay/etc/uci-defaults/40-hostname
@@ -0,0 +1,21 @@
+MODEL=$(cat /etc/board.json | jsonfilter -e '@["model"]["id"]')
+
+# Hostname prefix
+case $MODEL in
+    tl-*|archer-*)  VENDOR=tplink ;;
+    cf-*) VENDOR=comfast ;;
+    *) VENDOR=ap ;;
+esac
+
+# Network interface with relevant MAC address
+case $MODEL in
+    tl-wdr*) NIC=wlan1 ;;
+    archer-*)  NIC=eth1 ;;
+    cf-e380ac-v2) NIC=eth0 ;;
+    *) NIC=wlan0 ;;
+esac
+
+HOSTNAME=$VENDOR-$(cat /sys/class/net/$NIC/address | cut -d : -f 4- | sed -e 's/://g')
+uci set system.@system[0].hostname=$HOSTNAME
+uci set network.lan.hostname=$HOSTNAME
+
diff --git a/doc/overlay/etc/uci-defaults/50-access-point b/doc/overlay/etc/uci-defaults/50-access-point
new file mode 100644
index 0000000..a57ad51
--- /dev/null
+++ b/doc/overlay/etc/uci-defaults/50-access-point
@@ -0,0 +1,66 @@
+# Disable DHCP servers
+/etc/init.d/odhcpd disable
+/etc/init.d/dnsmasq disable
+
+# Remove firewall rules since AP bridges ethernet to wireless anyway
+uci delete firewall.@zone[1]
+uci delete firewall.@zone[0]
+uci delete firewall.@forwarding[0]
+for j in $(seq 0 10); do uci delete firewall.@rule[0]; done
+
+# Remove WAN interface
+uci delete network.wan
+uci delete network.wan6
+
+# Reconfigure DHCP client for bridge over LAN and WAN ports
+uci delete network.lan.ipaddr
+uci delete network.lan.netmask
+uci delete network.lan.ip6assign
+uci delete network.globals.ula_prefix
+uci delete network.@switch_vlan[1]
+uci delete dhcp.@dnsmasq[0].domain
+uci set network.lan.proto=dhcp
+uci set network.lan.ipv6=0
+uci set network.lan.ifname='eth0'
+uci set network.lan.stp=1
+
+# Radio ordering differs among models
+case $(uci get wireless.radio0.hwmode) in
+    11a) uci rename wireless.radio0=radio5ghz;;
+    11g) uci rename wireless.radio0=radio2ghz;;
+esac
+case $(uci get wireless.radio1.hwmode) in
+    11a) uci rename wireless.radio1=radio5ghz;;
+    11g) uci rename wireless.radio1=radio2ghz;;
+esac
+
+# Reset virtual SSID-s
+uci delete wireless.@wifi-iface[1]
+uci delete wireless.@wifi-iface[0]
+
+# Pseudorandomize channel selection, should work with 80MHz on 5GHz band
+case $(uci get system.@system[0].hostname | md5sum) in
+   1*|2*|3*|4*) uci set wireless.radio2ghz.channel=1; uci set wireless.radio5ghz.channel=36 ;;
+   5*|6*|7*|8*) uci set wireless.radio2ghz.channel=5; uci set wireless.radio5ghz.channel=52 ;;
+   9*|0*|a*|b*) uci set wireless.radio2ghz.channel=9; uci set wireless.radio5ghz.channel=100 ;;
+   c*|d*|e*|f*) uci set wireless.radio2ghz.channel=13; uci set wireless.radio5ghz.channel=132 ;;
+esac
+
+# Create bridge for guests
+uci set network.guest=interface
+uci set network.guest.proto='static'
+uci set network.guest.address='0.0.0.0'
+uci set network.guest.type='bridge'
+uci set network.guest.ifname='eth0.156' # tag id 156 for guest network
+uci set network.guest.ipaddr='0.0.0.0'
+uci set network.guest.ipv6=0
+uci set network.guest.stp=1
+
+# Disable switch tagging and bridge all ports on TP-Link WDR3600/WDR4300
+case $(cat /etc/board.json | jsonfilter -e '@["model"]["id"]') in
+    tl-wdr*)
+        uci set network.@switch[0].enable_vlan=0
+        uci set network.@switch_vlan[0].ports='0 1 2 3 4 5 6'
+    ;;
+    *) ;;
+esac