diff --git a/example/consul/config/service-defaults/unifi.hcl b/example/consul/config/service-defaults/unifi.hcl new file mode 100644 index 0000000..cf6ceee --- /dev/null +++ b/example/consul/config/service-defaults/unifi.hcl @@ -0,0 +1,3 @@ +Kind = "service-defaults" +Name = "unifi" +Protocol = "http" diff --git a/example/consul/config/service-intentions/unifi.hcl b/example/consul/config/service-intentions/unifi.hcl new file mode 100644 index 0000000..448a999 --- /dev/null +++ b/example/consul/config/service-intentions/unifi.hcl @@ -0,0 +1,16 @@ +Kind = "service-intentions" +Name = "unifi" +Sources = [ + { + Name = "traefik" + Permissions = [ + { + Action = "allow" + HTTP { + PathPrefix = "/" + Methods = ["GET","HEAD","POST","PUT","DELETE"] + } + } + ] + } +] diff --git a/example/images/ubnt-firmware-downloader/Dockerfile b/example/images/ubnt-firmware-downloader/Dockerfile new file mode 100644 index 0000000..b0e471c --- /dev/null +++ b/example/images/ubnt-firmware-downloader/Dockerfile @@ -0,0 +1,16 @@ +FROM danielberteaud/alpine:24.1-1 +MAINTAINER Daniel Berteaud + +ENV UBNT_UPDATE_API="https://fw-update.ubnt.com/api/firmware-latest?filter=eq~~product~~unifi-firmware&filter=eq~~channel~~release" \ + UBNT_FIRMWARE_DIR="/opt/unifi/app/data/firmware/" \ + UBNT_PLATFORMS= + +RUN set -eux &&\ + apk --no-cache upgrade &&\ + apk --no-cache add perl-libwww \ + perl-lwp-protocol-https \ + perl-json \ + supercronic + +COPY root/ / +CMD ["ubnt-firmware-downloader.sh"] diff --git a/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader b/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader new file mode 100755 index 0000000..f7f168b --- /dev/null +++ b/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader @@ -0,0 +1,141 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use JSON; +use LWP::UserAgent; +use File::Basename; +use File::Path qw(make_path); +use Digest::SHA; +use Data::Dumper; + +my $ubnt_api = $ENV{UBNT_UPDATE_API} || 'https://fw-update.ubnt.com/api/firmware-latest?filter=eq~~product~~unifi-firmware&filter=eq~~channel~~release'; +my $fw_dir = $ENV{UBNT_FIRMWARE_DIR} || '/opt/unifi/app/data/firmware/'; +my @dl_platform = split(/,/, $ENV{UBNT_PLATFORMS} || ""); +my $fw_cached = {}; +my $fw_entries = {}; +my $fw_devices = {}; +my $fw_latest = {}; + +# Ensure the list is empty if UBNT_PLATFORMS was empty +if (scalar @dl_platform > 0 and $dl_platform[0] eq "") { + @dl_platform = (); +} + +if (scalar @dl_platform > 0){ + print STDERR "Will only try to download firmware for platforms " . join(",", @dl_platform) . "\n"; +} + +# If the firmware_meta.json file exists, open it and read +# already present firmwares +if (-e "$fw_dir/firmware_meta.json"){ + open CACHED_FIRMWARES, "<$fw_dir/firmware_meta.json"; + my $data = ; + my $json = eval { + from_json($data); + }; + if (defined $json and defined $json->{cached_firmwares}){ + foreach my $fw (@{$json->{cached_firmwares}}){ + print STDERR "file $fw->{path} found\n"; + if (-e "$fw_dir/$fw->{path}"){ + $fw_entries->{$fw->{md5}} = $fw; + } + } + } + close CACHED_FIRMWARES; +} + +my $ua = LWP::UserAgent->new(timeout => 10); +$ua->env_proxy; + +# Ask ubnt API for the list of firmwares +my $resp = $ua->get($ubnt_api); + +if (not $resp->is_success){ + die "Couldn't fetch $ubnt_api : " . $resp->status_line . "\n"; +} + +my $firmwares = from_json($resp->decoded_content); + +FIRMWARE: foreach my $fw (@{$firmwares->{_embedded}->{firmware}}){ + my $version = $fw->{version}; + $version =~ s/^v//; + $version =~ s/\+/./g; + my $file = basename($fw->{_links}->{data}->{href}); + my $fw_path = "$fw_dir/$fw->{platform}/$version/$file"; + my $sha256; + + # If the firmware is already cached, we need to check its checksum + if (-e "$fw_path"){ + print STDERR "Found firmware for $fw->{platform} version $version, checking checksum of $fw_path\n"; + $sha256 = Digest::SHA->new("sha256")->addfile($fw_path)->hexdigest; + + if ($sha256 eq $fw->{sha256_checksum}){ + print STDERR "Checksum matched, firmware already cached\n"; + + $fw_entries->{$fw->{md5}} = { + md5 => $fw->{md5}, + version => $version, + size => $fw->{file_size}, + path => "$fw->{platform}/$file" + }; + + push @{$fw_devices->{$fw->{md5}}}, $fw->{platform}; + $fw_latest->{$fw->{md5}} = 1; + + # File is OK, just continue with the next firmware + next FIRMWARE; + } else { + print STDERR "Checksum mismatch: got $sha256 while expecting $fw->{sha256_checksum}, downloading again\n"; + } + } + + # If we restrict the list of platform to download firmwares for + # check the current firmware matches + if (scalar @dl_platform > 0 and not grep { $_ eq $fw->{platform} } @dl_platform){ + print STDERR "Platform $fw->{platform} is not in the list of downloads, skipping download\n"; + } else { + print STDERR "Downloading firmware from $fw->{_links}->{data}->{href}\n"; + make_path "$fw_dir/$fw->{platform}/$version/"; + #$resp = getstore($fw->{_links}->{data}->{href}, $fw_path); + $resp = $ua->get($fw->{_links}->{data}->{href}, ":content_file" => $fw_path); + if (not $resp->is_success){ + print STDERR "Error downloading $fw->{_links}->{data}->{href} : " . $resp->status_line ."\n"; + next FIRMWARE; + } + + $sha256 = Digest::SHA->new("sha256")->addfile($fw_path)->hexdigest; + if ($sha256 ne $fw->{sha256_checksum}){ + print STDERR "Checksum mismatch : got $sha256 while expecting $fw->{sha256_checksum}\n"; + next FIRMWARE; + } else { + print STDERR "Checksum correctly verified\n"; + push @{$fw_devices->{$fw->{md5}}}, $fw->{platform}; + $fw_latest->{$fw->{md5}} = 1; + $fw_entries->{$fw->{md5}} = { + md5 => $fw->{md5}, + version => $version, + size => $fw->{file_size}, + path => "$fw->{platform}/$file" + }; + } + } + + # In anycase, push this platform to the fw_devices hash + # so the list of devices will be complete + push @{$fw_devices->{$fw->{md5}}}, $fw->{platform}; +} + +print STDERR "Finished downloading firmwares, now building the firmware_meta.json file\n"; +foreach my $fw (sort { $fw_entries->{$a}->{version} cmp $fw_entries->{$b}->{version} } keys %{$fw_entries}){ + my $firmware = $fw_entries->{$fw}; + # Only override device list for latest firmwares (those returned by the ubnt API) + # for the previous one, we do not have fresh info, so just trust what was in the firmware_meta.json file + if (defined $fw_latest->{$fw_entries->{$fw}->{md5}}){ + $firmware->{devices} = $fw_devices->{$fw}; + } + push @{$fw_cached->{cached_firmwares}}, $firmware; +} + +open CACHED_FIRMWARES, ">$fw_dir/firmware_meta.json"; +print CACHED_FIRMWARES to_json($fw_cached); diff --git a/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader.sh b/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader.sh new file mode 100755 index 0000000..2da85bf --- /dev/null +++ b/example/images/ubnt-firmware-downloader/root/usr/local/bin/ubnt-firmware-downloader.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -euo pipefail + +/usr/local/bin/ubnt-firmware-downloader +# If a cron expression is defined, run a cron daemon +if [ -n "${UBNT_CRON}" ]; then + echo "Running using cron with expression ${UBNT_CRON}" + cat <<_EOF > /tmp/crontab + ${UBNT_CRON} /usr/local/bin/ubnt-firmware-downloader +_EOF + supercronic /tmp/crontab +fi + diff --git a/example/images/unifi/Dockerfile b/example/images/unifi/Dockerfile new file mode 100644 index 0000000..84ad195 --- /dev/null +++ b/example/images/unifi/Dockerfile @@ -0,0 +1,41 @@ +FROM danielberteaud/java:17.24.1-1 AS builder + +ARG UNIFI_VERSION=8.0.26 + +RUN set -euxo pipefail &&\ + apk --no-cache add curl ca-certificates unzip &&\ + cd /tmp &&\ + curl -sSLO https://www.ubnt.com/downloads/unifi/${UNIFI_VERSION}/UniFi.unix.zip &&\ + unzip UniFi.unix.zip &&\ + rm -f UniFi.unix.zip &&\ + ls -l &&\ + rm -f UniFi/bin/mongod &&\ + chown -R root:root UniFi + +FROM danielberteaud/java:17.24.1-1 +MAINTAINER Daniel Berteaud + +ENV JAVA_OPTS="-Djava.awt.headless=true -Dlogback.configurationFile=/opt/unifi/logback.xml --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED" \ + TZ=Europe/Paris \ + LANG=fr_FR.utf8 + +COPY --from=builder /tmp/UniFi /opt/unifi +COPY root/ / +RUN set -euxo pipefail &&\ + apk --no-cache upgrade &&\ + addgroup -g 8443 unifi &&\ + adduser --system --ingroup unifi --disabled-password --uid 8443 --home /opt/unifi --shell /sbin/nologin unifi &&\ + mkdir -p /data/unifi &&\ + mkdir -p /data/logs &&\ + chown unifi:unifi /data &&\ + chmod 700 /data &&\ + ln -s /data/unifi /opt/unifi/data &&\ + ln -s /data/logs /opt/unifi/logs + +EXPOSE 8443 8080 8843 3778 +USER unifi +VOLUME /data +WORKDIR /opt/unifi + +CMD ["sh", "-c", "java ${JAVA_OPTS} -jar /opt/unifi/lib/ace.jar start"] + diff --git a/example/images/unifi/root/opt/unifi/logback.xml b/example/images/unifi/root/opt/unifi/logback.xml new file mode 100644 index 0000000..da9e667 --- /dev/null +++ b/example/images/unifi/root/opt/unifi/logback.xml @@ -0,0 +1,11 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %highlight([%-5level]) %logger{36} - %msg%n + + + + + + diff --git a/example/prep.d/mv_conf.sh b/example/prep.d/mv_conf.sh new file mode 100755 index 0000000..8b40cc9 --- /dev/null +++ b/example/prep.d/mv_conf.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +set -eu + + + +if [ "unifi" != "unifi" ]; then + for DIR in vault consul nomad; do + if [ -d output/${DIR} ]; then + for FILE in $(find output/${DIR} -name "*unifi*.hcl" -type f); do + NEW_FILE=$(echo "${FILE}" | sed -E "s/unifi/unifi/g") + mv "${FILE}" "${NEW_FILE}" + done + fi + done +fi + + + diff --git a/example/unifi.nomad.hcl b/example/unifi.nomad.hcl new file mode 100644 index 0000000..c536108 --- /dev/null +++ b/example/unifi.nomad.hcl @@ -0,0 +1,359 @@ +job "unifi" { + datacenters = ["dc1"] + + + group "unifi" { + + network { + mode = "bridge" + port "stun" { + to = 3478 + } + } + + service { + name = "unifi" + port = 8888 + + connect { + sidecar_service { + } + sidecar_task { + resources { + cpu = 50 + memory = 64 + } + + } + } + + + tags = [ + "traefik.enable=true", + + # Note : no Host as inform requests are sent without. But it's binded to the dedicated entrypoint anyway + "traefik.http.routers.unifi-inform.rule=(Path(`/inform`) && Method(`POST`)) || (PathPrefix(`/dl/firmware-cached`) && (Method(`GET`) || Method(`HEAD`)))", + "traefik.http.routers.unifi-inform.entrypoints=unifi-inform", + "traefik.http.routers.unifi-inform.middlewares=rate-limit-std@file,inflight-std@file", + + "traefik.http.routers.unifi-controller.rule=Host(`unifi.example.org`)", + "traefik.http.routers.unifi-controller.entrypoints=https", + "traefik.http.routers.unifi-controller.tls=true", + "traefik.http.routers.unifi-controller.middlewares=rate-limit-std@file,security-headers@file,compression@file,csp-relaxed@file", + + "traefik.http.routers.unifi-portal.rule=Host(`unifi-portal.example.org`) && PathPrefix(`/guest`)", + "traefik.http.routers.unifi-portal.entrypoints=unifi-portal", + "traefik.http.routers.unifi-portal.tls=true", + "traefik.http.routers.unifi-portal.middlewares=rate-limit-std@file,inflight-std@file,security-headers@file,hsts@file,compression@file,csp-relaxed@file" + + ] + } + + service { + name = "unifi-stun" + port = "stun" + + tags = [ + "traefik.enable=true", + "traefik.udp.routers.unifi-stun.entrypoints=unifi-stun", + "traefik.consulcatalog.connect=false" + ] + } + service { + name = "unifi-mongo" + port = 27017 + + check { + type = "script" + command = "sh" + args = ["-c", "mongo --quiet --eval 'db.runCommand(\"ping\").ok'"] + interval = "30s" + timeout = "5s" + task = "mongo" + + check_restart { + limit = 4 + grace = "3m" + } + } + } + + + volume "mongo" { + source = "unifi-mongo" + type = "csi" + access_mode = "single-node-writer" + attachment_mode = "file-system" + } + + + + volume "data" { + source = "unifi-data" + type = "csi" + access_mode = "single-node-writer" + attachment_mode = "file-system" + } + + + # wait for required services tp be ready before starting the main task + task "wait-for" { + + driver = "docker" + user = 1053 + + config { + image = "danielberteaud/wait-for:24.1-1" + readonly_rootfs = true + pids_limit = 20 + } + + lifecycle { + hook = "prestart" + } + + env { + SERVICE_0 = "unifi-mongo.service.consul" + } + + resources { + cpu = 10 + memory = 10 + memory_max = 30 + } + } + + + + task "nginx" { + driver = "docker" + user = 8306 + + lifecycle { + hook = "poststart" + sidecar = "true" + } + + config { + image = "nginxinc/nginx-unprivileged:alpine" + volumes = ["local/nginx.conf:/etc/nginx/conf.d/default.conf"] + } + + template { + data = <<_EOF +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 127.0.0.1:8888; + server_name _; + server_tokens off; + root /usr/share/html; + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_socket_keepalive on; + client_max_body_size 100m; + + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + # Inform endpoint + location ~ ^/(inform|dl/firmware-cached).* { + if ($request_method !~ ^(GET|HEAD|POST)$ ) { + return 405; + } + proxy_pass http://localhost:8080; + } + + # Guest portal + location /guest/ { + if ($request_method !~ ^(GET|HEAD|POST)$ ) { + return 405; + } + proxy_pass https://localhost:8843; + } + + # Main console + location / { + if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE)$ ) { + return 405; + } + proxy_pass https://localhost:8443; + } +} + + +_EOF + destination = "local/nginx.conf" + } + + resources { + cpu = 10 + memory = 15 + } + + } + + task "controller" { + + leader = true + driver = "docker" + + config { + image = "danielberteaud/unifi:8.0.26-1" + volumes = [ + "local/init-system.properties.sh:/entrypoint.d/10-init-system.properties.sh" + ] + + mount { + type = "tmpfs" + target = "/opt/unifi/run" + } + + + } + + vault { + policies = ["unifi"] + env = false + disable_file = true + } + + env { + + + + LANG = "fr_FR.utf8" + TZ = "Europe/Paris" + + } + + template { + data = <<_EOF +unifi.http.port=8080 +unifi.https.port=8443 +portal.http.port=8880 +portal.https.port=8843 +unifi.stun.port={{ env "NOMAD_PORT_stun" }} +unifi.db.nojournal=true +db.mongo.local=false +db.mongo.uri=mongodb://127.0.0.1:27017/unifi? +statdb.mongo.uri=mongodb://127.0.0.1:27017/unifi_stats? +debug.device=info +debug.mgmt=info +debug.system=info +debug.sdn=warn +autobackup.dir=/data/backup + +_EOF + destination = "secrets/system.properties" + } + + template { + data = <<_EOF +#!/bin/sh + +# vim: syntax=sh +set -euo pipefail + +mkdir -p /data/unifi +mkdir -p /data/logs + +if [ \! -f "/opt/unifi/data/system.properties" ]; then + echo "System initialization, copy the default system.properties" + cp /secrets/system.properties /opt/unifi/data/system.properties +else + for PROP in $(grep -vE '^(\s*$|#)' /secrets/system.properties); do + KEY=$(echo ${PROP} | cut -d= -f1) + VALUE=$(echo ${PROP} | cut -d= -f2) + if grep -q -E "^${KEY}=" /opt/unifi/data/system.properties; then + if grep -q -E "^${PROP}" /opt/unifi/data/system.properties; then + echo "${PROP} already set in system.properties" + else + echo "Updating ${PROP} in system.properties" + sed -i -E "s|^${KEY}=.*|${PROP}|" /opt/unifi/data/system.properties + fi + else + echo "Adding ${PROP} in system.properties" + echo ${PROP} >> /opt/unifi/data/system.properties + fi + done +fi + +_EOF + destination = "local/init-system.properties.sh" + perms = "755" + } + + volume_mount { + volume = "data" + destination = "/data" + } + + resources { + cpu = 200 + memory = 1024 + } + + + } + + task "mongo" { + driver = "docker" + + lifecycle { + hook = "prestart" + sidecar = true + } + + config { + image = "danielberteaud/mongo:5.0.24.1-1" + command = "mongod" + args = [ + "--config", + "/local/mongod.conf" + ] + } + + template { + data = <<_EOF +net: + bindIp: 127.0.0.1 +storage: + dbPath: /data/db + directoryPerDB: true + wiredTiger: + engineConfig: + directoryForIndexes: true + journalCompressor: snappy + collectionConfig: + blockCompressor: snappy + + +_EOF + destination = "local/mongod.conf" + } + + volume_mount { + volume = "mongo" + destination = "/data/db" + } + + resources { + cpu = 100 + memory = 256 + } + + + } + } +} + diff --git a/example/vault/policies/unifi.hcl b/example/vault/policies/unifi.hcl new file mode 100644 index 0000000..8e0c828 --- /dev/null +++ b/example/vault/policies/unifi.hcl @@ -0,0 +1,3 @@ +path "kv/data/service/unifi" { + capabilities = ["read"] +}