The HU-058 (often sold as the "HU-058 DIY Digital Electronic Clock Kit" on AliExpress) is an attractive desktop clock with large, four-digit 7-segment RGB LED blocks. Under the hood, rather than driving multiplexed segments through standard shift registers or dedicated MAX7219s, the kit uses two AiP33628 16-channel constant-current LED matrix drivers. When replaced or augmented with an ESP32 board, it becomes a versatile ESPHome-powered smart display.

The physical layout features four 7-segment digits partitioned into two hour digits and two minute digits. In the middle space sits a colon (two distinct round dots) and a horizontal "date dash" bar (annunciator segment, LED 17). In standard clock mode, the colon blinks with the seconds or network state, but the date dash sits completely dark.

led-positions.png

Having an unlit RGB LED in the exact center of a desk clock seemed like a missed opportunity. I have a local Plantower PMS3003 particulate matter sensor streaming real-time PM2.5 readings over MQTT on my home network. The plan: decouple the date dash LED from the rest of the display, expose it to Home Assistant as an independent RGB light entity, and create a smooth ambient indicator that continuously glides from green through yellow to red as particulate concentration changes—all without disrupting the clock display, color cycles, or night standby modes.

Here is the whole journey: analyzing community forks, dissecting the AiP33628 driver architecture, patching the C++ component in ESPHome, building inside a libvirt Home Assistant container, and tuning a piecewise linear gradient in Jinja2.

1. Exploring the Community Forks: Hunting for LDR & Sensor Code

The foundational ESPHome implementation for this clock is maintained in misterblack1/HU-058_ESPHome. Before writing new code from scratch, I queried the GitHub API across all 33 network forks to see what other builders had already implemented:

gh api repos/misterblack1/HU-058_ESPHome/forks --paginate --jq '.[].full_name'

Of the 33 forks, 15 had divergent commits ahead of upstream. Fetching their commit logs revealed two key findings:

  • LDR (Light Dependent Resistor) & NTC Support: Fork mrceedjay/HU-058_ESPHome-NTC-LDR-Buzzer was 55 commits ahead. It added support for the onboard photodiode/LDR on ADC GPIO34, an NTC thermistor on GPIO35, and an active buzzer on GPIO25.
  • ESP8266 Ports: Fork herrceedjay/HU-058_ESPHome-ESP8266 adapted the timings for the ESP8266 architecture.

Inspecting the LDR implementation confirmed that GPIO34 provides calibrated light readings out of the box. I switched my working branch to mrceedjay/main and saved all remote diffs locally in remote/ for reference.

2. Hardware Architecture & The AiP33628 Protocol

To control an individual LED independently, you have to understand how the AiP33628 matrix driver functions.

The clock houses two AiP33628 driver ICs, each capable of sinking 16 channels across 4 shorted COM line pairs (0x30, 0x0C, 0x03, 0xC0), giving a 1/4 scan cycle. The display is divided into four blocks:

Block Digit Driver COM Low COM High Annunciator Segment
0 (Block 1) Hour tens Driver 1 0x30 0xC0 AM indicator
1 (Block 2) Hour ones Driver 1 0x03 0x0C Colon (upper & lower dots)
2 (Block 3) Minute tens Driver 2 0x30 0xC0 Date dash (LED 17)
3 (Block 4) Minute ones Driver 2 0x03 0x0C Degree mark (°)

Notice where the annunciators sit:

  • The colon dots belong to Block 1 (Driver 1, COM high and COM low).
  • The Date Dash sits physically between digits 2 and 3, but electrically belongs to Block 2 (Driver 2, COM high 0xC0, Segment 7 SEG_ANNUN, LED ID 17).

The Current & Color Constraint: The AiP33628 has a global current setting IS[3:0] (ranging from 2.5mA at 0x0 to 40.4mA at 0xF) that applies globally to the entire IC. However, channel duty cycles are driven by 4-bit Binary Code Modulation (BCM), providing 16 individual brightness steps (0 to 15) for each color channel (Red, Green, Blue) of each LED.

This meant: while overall peak current is global, we can independently modulate the RGB duty levels of LED 17 without interfering with the colors or brightness of the time digits!

3. Modifying the ESPHome C++ Component

In the stock driver, Aip33628Panel::loop() wiped the scan buffer on every frame and conditionally drew digits, colon dots, and the AM mark. The date dash was never written during time mode, and apply_colors_() applied uniform base colors or cyclic effects across all segments.

To make the date dash an autonomous citizen, we made three surgical changes:

A. Driver Header (components/aip33628/aip33628.h)

We added state tracking for the dash's active state, custom RGB levels, and brightness:

// Date dash (LED 17, Block 2 SEG_ANNUN) independent control.
void set_date_dash(bool on) {
  dash_enabled_ = on;
  dirty_ = true;
}

void set_date_dash_color(bool on, float r, float g, float b, float brightness) {
  dash_enabled_ = on && brightness > 0.0f;
  dash_custom_color_ = true;
  dash_rgb_[0] = r < 0.0f ? 0.0f : (r > 1.0f ? 1.0f : r);
  dash_rgb_[1] = g < 0.0f ? 0.0f : (g > 1.0f ? 1.0f : g);
  dash_rgb_[2] = b < 0.0f ? 0.0f : (b > 1.0f ? 1.0f : b);
  dash_brightness_ = brightness < 0.0f ? 0.0f : (brightness > 1.0f ? 1.0f : brightness);
  dirty_ = true;
  apply_colors_();
}

void clear_date_dash_color() {
  dash_custom_color_ = false;
  dirty_ = true;
  apply_colors_();
}

We also extended the Aip33628Light output wrapper with set_is_dash(bool) so the same C++ class can drive either the main display or the dash entity.

B. Driver Implementation (components/aip33628/aip33628.cpp)

In apply_colors_(), we isolate the dash's 4-bit BCM levels so that running effects (like Color Cycle or Flash) do not overwrite it:

for (int blk = 0; blk < 4; blk++) {
  for (int seg = 0; seg < 9; seg++) {
    if (blk == 2 && seg == SEG_ANNUN && dash_custom_color_) {
      level_[blk][seg][CH_RED]   = duty_level(dash_rgb_[0] * dash_brightness_);
      level_[blk][seg][CH_GREEN] = duty_level(dash_rgb_[1] * dash_brightness_);
      level_[blk][seg][CH_BLUE]  = duty_level(dash_rgb_[2] * dash_brightness_);
      continue;
    }
    const float *c = pos_set_[blk][seg]  ? pos_rgb_[blk][seg]
                     : digit_set_[blk]   ? digit_rgb_[blk]
                                         : base_rgb_;
    level_[blk][seg][CH_RED]   = duty_level(c[0] * envelope_);
    level_[blk][seg][CH_GREEN] = duty_level(c[1] * envelope_);
    level_[blk][seg][CH_BLUE]  = duty_level(c[2] * envelope_);
  }
}

Next, in loop(), we make sure the dash is written to the scan schedule during normal clock rendering:

if (dash_enabled_) {
  write_pos_(2, SEG_ANNUN, true);  // Date dash indicator
}

Crucially, we also added the dash write inside the if (!enabled_) standby block:

if (!enabled_) {
  if (dash_enabled_) {
    write_pos_(2, SEG_ANNUN, true);
  }
  render_();
  return;
}

Why? I have an automation that turns off the clock display at 22:00 every night. With this check in place, even when the clock digits are completely turned off for sleep, the date dash continues to glow quietly as a low-intensity air quality night-light!

C. Schema & Configuration (light.py and clock.yaml)

In components/aip33628/light.py, we added a configuration option type: date_dash:

CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend({
    cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(Aip33628Light),
    cv.GenerateID(CONF_AIP33628_ID): cv.use_id(Aip33628Panel),
    cv.Optional("type", default="display"): cv.one_of("display", "date_dash", lower=True),
}).extend(cv.COMPONENT_SCHEMA)

Then in clock.yaml, we exposed the native RGB light and a companion template switch:

light:
  - platform: aip33628
    id: date_dash_light
    aip33628_id: panel
    type: date_dash
    name: Date Dash
    restore_mode: RESTORE_DEFAULT_OFF
    gamma_correct: 1.0

switch:
  - platform: template
    id: date_dash_switch
    name: Date Dash
    icon: mdi:minus
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    turn_on_action:
      - lambda: |-
          id(panel)->clear_date_dash_color();
          id(panel)->set_date_dash(true);
      - light.turn_on: date_dash_light
    turn_off_action:
      - lambda: |-
          id(panel)->set_date_dash(false);
      - light.turn_off: date_dash_light

4. Build Infrastructure & OTA Deployment

Rather than setting up an ESP-IDF toolchain on my laptop, I compiled the firmware directly inside the Home Assistant virtual machine running on my local KVM/libvirt hypervisor:

docker exec app_5c53de3b_esphome esphome compile /config/esphome/clock.yaml

ESPHome 2026.9.1 compiled cleanly:

  • RAM Usage: 52,912 / 180,736 bytes (29.3%)
  • Flash Usage: 965,951 / 1,835,008 bytes (52.6%)
  • Binary Output: wifi-clock.bin (966,064 bytes)

Because the clock was already connected to Wi-Fi at 192.168.3.108, I pushed the binary over-the-air:

docker exec app_5c53de3b_esphome esphome upload /config/esphome/clock.yaml --device 192.168.3.108

In 8.85 seconds, the ESPHome encrypted OTA protocol transferred the firmware, rebooted the ESP32, and the serial log resumed streaming sensor telemetry:

INFO Successfully connected to wifi-clock @ 192.168.3.108 in 0.043s
[12:35:21.307][I][app:151]: ESPHome version 2026.9.1 compiled on 2026-10-18 12:29:47 +0200
[12:35:24.575][S][sensor]: 'Light' >> 35 %
[12:35:24.575][S][sensor]: 'Temperature' >> 25.5 °C

5. The "Gray Dash" Mystery & MQTT Sensor Integration

Once flashed, Home Assistant automatically discovered the new entities:

  • light.living_room_wifi_clock_date_dash
  • switch.living_room_wifi_clock_date_dash

I flipped the switch on. The date dash illuminated immediately... but it was a faint grayish-white, not the vibrant air quality indicator I wanted!

What happened?

  1. The companion template switch was designed to default to the base display color via clear_date_dash_color(), which falls back to white ([1.0, 1.0, 1.0]).
  2. My initial automation used discrete Home Assistant choose blocks with numeric_state ranges. Due to an indentation quirk in YAML under choose:, the automation had failed to trigger.

Checking the MQTT stream from my Raspberry Pi (rpi2) confirmed that the Plantower PMS3003 was broadcasting real-time particulate metrics every 2 seconds:

$ mosquitto_sub -h rpi2 -t "air/pms3003/#" -v
air/pms3003/pm1_0 1
air/pms3003/pm2_5 1
air/pms3003/pm10 1

The sensor data was live and accurate. But instead of fixing a stepped choose table with arbitrary jumps (e.g. green jumping straight to yellow at 12 µg/m³), I decided to implement what I actually wanted: a continuous color gradient.

6. Implementing the Continuous Color Gradient

A stepped color transition on a living room clock is distracting; your eye catches the sudden jump from green to yellow. A continuous gradient, however, smoothly reflects atmospheric changes without drawing sudden attention.

We mapped PM2.5 concentrations from 0 to 100 µg/m³ using a 3-phase piecewise linear interpolation:

  • Phase 1: Good to Moderate (0 ≤ PM2.5 ≤ 25 µg/m³)
    Red ramps up smoothly from 0 to 255 while Green stays at 255. Blue is 0.
    Result: Pure Green ([0, 255, 0]) → Lime → Pure Yellow ([255, 255, 0]).
  • Phase 2: Moderate to Unhealthy (25 < PM2.5 ≤ 55 µg/m³)
    Red stays pinned at 255 while Green decreases smoothly from 255 down to 0.
    Result: Yellow ([255, 255, 0]) → Amber → Orange → Pure Red ([255, 0, 0]).
  • Phase 3: Very Unhealthy / Hazardous (PM2.5 > 55 µg/m³)
    Red stays at 255, Green is 0, and Blue increases towards 180.
    Result: Red → Crimson → Magenta ([255, 0, 180]).

Here is the complete Home Assistant automation added to automations.yaml:

- id: 'clock_date_dash_pms3003'
  alias: 'Clock Date Dash PMS3003 AQI'
  description: 'Smoothly modulates Date Dash LED color from green to red based on PMS3003 PM2.5 reading'
  triggers:
  - trigger: state
    entity_id: sensor.pms3003_pm2_5
  - trigger: homeassistant
    event: start
  conditions: []
  actions:
  - action: light.turn_on
    target:
      entity_id: light.living_room_wifi_clock_date_dash
    data:
      brightness: 160
      transition: 2
      rgb_color: >-
        {% set raw_pm = states('sensor.pms3003_pm2_5') | float(0) %}
        {% set pm = [0.0, [raw_pm, 100.0] | min] | max %}
        {% if pm <= 25.0 %}
          {% set r = ((pm / 25.0) * 255.0) | round | int %}
          {% set g = 255 %}
          {% set b = 0 %}
        {% elif pm <= 55.0 %}
          {% set r = 255 %}
          {% set g = (((55.0 - pm) / 30.0) * 255.0) | round | int %}
          {% set b = 0 %}
        {% else %}
          {% set r = 255 %}
          {% set g = 0 %}
          {% set b = (((pm - 55.0) / 45.0) * 180.0) | round | int %}
        {% endif %}
        [{{ r }}, {{ g }}, {{ b }}]
  mode: restart

Why transition: 2 is magical: In ESPHome, Aip33628Light::write_state() extracts normalized color ratios and gamma-corrected brightness on every tick of the transition curve. When Home Assistant requests a 2-second transition, the ESP32 calculates intermediate 4-bit BCM duty steps at 25Hz. Whenever particulate levels drift, the dash gently glides into its new hue over two seconds.

7. Verification & Results

After validating the configuration with ha core check and restarting Home Assistant Core, the automation triggered on boot.

With the room air currently pristine at PM2.5 = 1 µg/m³:

r = round((1.0 / 25.0) * 255) = 10
g = 255
b = 0
rgb_color = [10, 255, 0]

The date dash glows with a rich, vibrant emerald green. During kitchen cooking tests when PM2.5 jumped to 38 µg/m³, the dash smoothly shifted through lemon-lime and yellow into a warm amber-orange, fading back to green as ventilation cleared the room.

Summary

Repurposing unused display segments is one of the most satisfying hardware hacks:

  • The AiP33628 driver's 4-bit Binary Code Modulation (BCM) allows independent per-LED duty modulation even when global current IS[3:0] is fixed.
  • Modifying ESPHome's custom components allowed clean isolation of LED 17 (Block 2 SEG_ANNUN) without interfering with the main clock display or digit cycle effects.
  • Using a continuous piecewise linear gradient in Home Assistant Jinja2 with native ESPHome transitions transforms an idle LED into an elegant, non-intrusive environmental display.

All code changes are at https://github.com/dpavlin/HU-058_ESPHome-NTC-LDR-Buzzer.

We recently deployed a department Synology DiskStation (DS223 running DSM 7.4.1) for collaborative research data storage. The requirement was simple: allow researchers from our faculty (FFZG) as well as colleagues from external Croatian academic institutions to log in using their national federated AAI@EduHr credentials, mapping them to local NAS accounts with private home directories, storage quotas, and shared folder ACLs.

We did not want to maintain local passwords, nor did we want to run a heavy external identity broker (like Keycloak or FreeIPA) just for a storage appliance. Synology DSM has native support for both SAML 2.0 and OpenID Connect (OIDC) SSO clients.

However, connecting Synology DSM to SRCE's SimpleSAMLphp-based AAI@EduHr identity federation revealed two interesting protocol mismatches that caused both SAML and OIDC to fail out of the box. Here is what went wrong and how we solved it with a 100-line local claim flattener daemon.

The Synology Authorization Model

Before looking at the federation protocols, it is important to understand how Synology DSM handles SSO authorization:

  • No Just-In-Time (JIT) provisioning: DSM does not auto-create local accounts upon successful SSO callback. If an authenticated user does not already match a provisioned DSM account, login fails with an authorization error. This is actually a great security boundary: uninvited university users cannot access the NAS.
  • No '@' characters in local usernames: Synology reserves the @ character exclusively for Active Directory and LDAP domain bindings (e.g. user@domain). Attempting to create a local user with @ fails with error code 0x0D00.

This immediately created a constraint: whatever identifier the upstream IdP passes as the username, it must match our local Unix username (e.g. dpavlin), not a scoped email-like principal.

Attempt 1: SAML 2.0 (The NameID Dilemma)

SAML 2.0 is the bedrock of AAI@EduHr and eduGAIN. Synology DSM provides a native SAML Service Provider (SP), so we registered the NAS in SRCE's Registar resursa.

In DSM's SAML implementation, DSM parses <saml:NameID> and unconditionally treats it as the username to authorize.

However, the Registar resursa web dropdown only allows two options for the SAML NameID attribute:

  1. hrEduPersonUniqueID -- Formatted as username@institution.hr. Because DSM local accounts reject @, this cannot match a local account without joining the NAS to an Active Directory domain.
  2. hrEduPersonPersistentID -- A pseudonymous 32-character hexadecimal hash.

Using hrEduPersonPersistentID resulted in DSM attempting to look up the hash as the local account, failing immediately:

User [303a3b0f72c5e29bcbdf35cab3826e62] failed to sign in to [DSM] via [sso] due to authorization failure.

The standard LDAP short username attribute uid exists in the directory, but it cannot be selected as the SAML NameID through the standard self-service registry UI.

Attempt 2: Switching to OpenID Connect (OIDC)

Synology's OIDC SSO client has one critical advantage over its SAML implementation: a configurable "Username claim" field. Instead of being locked to the opaque sub claim, you can tell DSM which claim in the token/userinfo payload represents the username!

We requested an OIDC client registration in Registar resursa, requested the uid scope, and SRCE approved it.

We configured DSM's OIDC client:

  • Well-known URL: https://login.aaiedu.hr/.well-known/openid-configuration
  • Scopes: openid profile email uid
  • Username claim: uid

We clicked "Sign in with SSO", authenticated at https://login.aaiedu.hr/, were redirected back to the NAS, and... DSM popped up an error:

"Your SSO is not properly configured."

The Root Cause: JSON Array vs Scalar String Mismatch

Inspecting /var/log/messages on the Synology NAS revealed the exact C++ stack trace from libsynosso.so:

synoscgi_SYNO.API.Auth_7_login: (json_value.cpp:905)JSON_FAIL_MESSAGE("Type is not convertible to string")
synoscgi_SYNO.API.Auth_7_login: oidc_auth.cpp:70 Get sso User failed. Reason: Type is not convertible to string
synoscgi_SYNO.API.Auth_7_login: pam_syno_sso.cpp:123 (euid=0) Failed [username.empty()]
synoscgi_SYNO.API.Auth_7_login: login.c:1162 Get uid/gid fail for user []. [0x1D00 user_db_get.c:36]

Why did oidc_auth.cpp throw Type is not convertible to string?

Let's look at what SRCE's SimpleSAMLphp OIDC module returns at /userinfo:

{
  "sub": "303a3b0f72c5e29bcbdf35cab3826e62",
  "uid": [
    "dpavlin"
  ],
  "hrEduPersonUniqueID": [
    "dpavlin@ffzg.hr"
  ],
  "mail": [
    "dpavlin@ffzg.hr"
  ]
}

Because SimpleSAMLphp maps LDAP attributes (which are inherently multi-valued arrays in directory schemas), it encodes all user claims as JSON arrays. The only primitive scalar string in the payload is sub.

Synology DSM's compiled C++ OIDC handler takes the configured Username claim and calls val.asString(), since it's Json::arrayValue, jsoncpp throws an exception, leaving the extracted username empty and terminating the authentication flow.

The Solution: A Local OIDC Claim Flattener

We did not want to set up an external virtual machine or a heavyweight proxy just to unwrap a single JSON array. The NAS runs Linux, Nginx, and Python 3.

We deployed a small Python HTTP daemon on 127.0.0.1:8089 that acts as an in-line bridge between DSM and AAI@EduHr:

  1. OIDC Discovery: Intercepts /.well-known/openid-configuration from AAI@EduHr and overrides only userinfo_endpoint to point through our local Nginx bridge.
  2. Userinfo Proxy: Intercepts /userinfo, forwards the incoming Bearer token to https://login.aaiedu.hr/sso/module.php/oidc/userinfo, parses the response, and flattens single-element arrays into scalar strings:
    {"uid": ["dpavlin"]}  -->  {"uid": "dpavlin"}
  3. Zero Touch on Credentials: All user authentication redirects (authorization_endpoint) and token exchanges (token_endpoint) remain direct between DSM/browser and login.aaiedu.hr.

Implementation Details

1. The Flattener Daemon (/usr/local/bin/aai-oidc-flattener.py)

#!/usr/bin/env python3
import sys
import json
import urllib.request
import urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime

UPSTREAM_WELLKNOWN = "https://login.aaiedu.hr/.well-known/openid-configuration"
UPSTREAM_USERINFO = "https://login.aaiedu.hr/sso/module.php/oidc/userinfo"
PUBLIC_USERINFO_URL = "https://XXX-nas.ffzg.unizg.hr:5001/aai-oidc/userinfo"

def log(msg):
    print(f"[{datetime.now().isoformat()}] [aai-oidc-flattener] {msg}", flush=True)

class OIDCFlattenerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        path = self.path.split("?")[0]
        if path in ("/.well-known/openid-configuration", "/aai-oidc/.well-known/openid-configuration"):
            self.handle_wellknown()
        elif path in ("/userinfo", "/aai-oidc/userinfo"):
            self.handle_userinfo()
        else:
            self.send_error(404, "Endpoint not found")

    def handle_wellknown(self):
        req = urllib.request.Request(UPSTREAM_WELLKNOWN, headers={"User-Agent": "Synology-AAI-OIDC-Bridge/1.0"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        
        # Override userinfo_endpoint to route through this bridge
        data["userinfo_endpoint"] = PUBLIC_USERINFO_URL
        payload = json.dumps(data, indent=2).encode("utf-8")
        
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def handle_userinfo(self):
        auth_header = self.headers.get("Authorization")
        if not auth_header:
            self.send_error(401, "Missing Authorization header")
            return

        req = urllib.request.Request(
            UPSTREAM_USERINFO,
            headers={"Authorization": auth_header, "User-Agent": "Synology-AAI-OIDC-Bridge/1.0"}
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            claims = json.loads(resp.read().decode("utf-8"))

        # Flatten single-item array claims into primitive strings
        flattened = {}
        for k, v in claims.items():
            if isinstance(v, list) and len(v) == 1:
                flattened[k] = v[0]
            else:
                flattened[k] = v

        log(f"Flattened user claim uid: {flattened.get('uid')}")
        payload = json.dumps(flattened).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

if __name__ == "__main__":
    server = HTTPServer(("127.0.0.1", 8089), OIDCFlattenerHandler)
    server.serve_forever()

2. Nginx Reverse Proxy Route (/etc/nginx/conf.d/dsm.oidc-bridge.conf)

Synology terminates TLS on port 5001 with its Let's Encrypt certificate. We pass the /aai-oidc/ prefix directly to our local Python daemon:

location ^~ /aai-oidc/ {
    proxy_pass http://127.0.0.1:8089/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

3. Systemd Unit (/etc/systemd/system/aai-oidc-flattener.service)

[Unit]
Description=AAI@EduHr OIDC Claim Flattener Bridge for Synology DSM
After=network.target nginx.service

[Service]
Type=simple
User=root
ExecStart=/usr/bin/python3 /usr/local/bin/aai-oidc-flattener.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

4. DSM Configuration

In Synology DSM (Control Panel > Domain/LDAP > SSO Client > OpenID Connect), configure:

  • Profile name: AAIEduHr
  • Well-known URL: https://XXX-nas.ffzg.unizg.hr:5001/aai-oidc/.well-known/openid-configuration
  • Application ID / Secret: As assigned in Registar resursa
  • Scope: openid profile email uid
  • Username claim: uid

Verification

With the flattener running, clicking "Sign in with SSO" initiates the authorization code grant flow:

[2026-10-01T08:58:37.382015] [aai-oidc-flattener] Serving modified openid-configuration...
[2026-10-01T08:58:43.084128] [aai-oidc-flattener] Fetching userinfo from upstream AAI...
[2026-10-01T08:58:43.208491] [aai-oidc-flattener] Raw upstream claims received: ['sub', 'uid', 'hrEduPersonUniqueID', 'mail']
[2026-10-01T08:58:43.209102] [aai-oidc-flattener] Flattened user claim uid: dpavlin
[2026-10-01T08:58:43.210411] [aai-oidc-flattener] Successfully returned flattened userinfo to DSM

Synology DSM's oidc_auth.cpp receives "uid": "dpavlin" as a scalar string, extracts the username without throwing an exception, finds the local DSM user dpavlin, and immediately creates a valid DSM session.

Summary

If you are integrating Synology DSM with an enterprise or academic SimpleSAMLphp IdP:

  • SAML will fight you if your IdP only exposes user@domain or opaque hashes as the NameID, because DSM local accounts strictly forbid @.
  • OIDC lets you select an arbitrary claim like uid, but SimpleSAMLphp's LDAP schema heritage will emit arrays ({"uid": ["user"]}), which crashes DSM's C++ JSON parser.
  • A tiny local Python proxy rewrites just enough of the discovery metadata to unpack the array, giving you clean federated SSO with zero external server dependencies.

I have already written about our attempts to keep Koha running under the load of web crawlers a year ago in Protecting Koha from web crawlers.

However, since then, the situation has gotten worse. There are more crawlers, some of them are really poorly written and get stuck in loops, and some of them are so aggressive that they result in a denial of service.

fail2ban-month.png

So, to fight that, we extended our approach into more comprehensive rules which deny requests, which fail2ban detects and adds to the iptables firewall so that the load of the server is under control.

Our current setup looks like this:

Apache Configuration (/etc/apache2/conf-available/block-aggressive-bots.conf)

# FFZG Global Bot Mitigation Policy - Version 3.15 (Referer Priority)

# 1. LEGITIMATE SOURCE DEFINITIONS
# Mandate HTTPS Referer
SetEnvIfNoCase Referer "^https://koha\.ffzg\.(hr|unizg\.hr)" local_referer
SetEnvIfNoCase Referer "^https://knjiznica\.ffzg\.unizg\.hr" local_referer
SetEnvIfNoCase Referer "^https://.*koha\.rot13\.org" local_referer
SetEnvIfNoCase Referer "^https://.*koha-dev\.rot13\.org" local_referer
SetEnvIfNoCase Referer "^https://katalog\.(bioetika|hpm)\.hr" local_referer

# 2. IDENTIFY EXPENSIVE ENDPOINTS
SetEnvIf Request_URI "^/(cgi-bin/koha/opac-|opac/opac-|search|tags)" expensive_script
SetEnvIf Request_URI "opac-((ISBD)?detail|user|shelves|discharge)\.pl" !expensive_script

<Location />
    <RequireAll>
        Require all granted
        
        # --- GROUP 1: PERIMETER BLOCKS (Hard Denials) ---
        Require not ip 43.0.0.0/8
        Require not ip 47.74.0.0/11
        Require not ip 101.47.0.0/16
        Require not ip 116.179.0.0/16
        Require not ip 119.249.0.0/16
        Require not ip 150.158.0.0/16
        Require not ip 220.181.0.0/16
        
        # --- GROUP 2: BEHAVIORAL/SIGNATURE BLOCKS (Hard Denials) ---
        # Block RSS/Shelf harvest
        Require not expr "%{QUERY_STRING} =~ /format=rss/i"
        Require not expr "%{QUERY_STRING} =~ /shelfbrowse_itemnumber/i"
        
        # Block known high-volume specific malicious bots
        # NOTE: GROUP 2 denials run before the RequireAny/local_referer check,
        # so these block unconditionally even if bot spoofs a local referer.
        Require not expr "req('User-Agent') =~ /(GPTBot|ChatGPT-User|Sogou|Thinkbot|Applebot|YandexBot|OAI-SearchBot|ClaudeBot|TikTokSpider|Bytespider|PetalBot|AhrefsBot|DotBot|PerplexityBot|Barkrowler|megaindex)/i"
        
        # --- GROUP 3: CONDITIONAL ACCESS ---
        <RequireAny>
            # Trusted IPs (Loopback, Private Network, & Local Subnet)
            Require ip 127.0.0.1
            Require ip ::1
            Require ip 10.0.0.0/8
            Require ip 193.198.212.0/22

            # 3A. Allowlisted Search Engines (Global Bypass for Keywords/Shielding)
            Require expr "req('User-Agent') =~ /(Googlebot|bingbot|DuckDuckBot|archive\.org_bot)/i"
            
            # 3B. Valid Internal HTTPS Referer (Human-like browsing)
            # This allows access even if UA has bot keywords (safety for humans)
            Require env local_referer
            
            # 3C. Traffic without Referer (Direct Entry)
            <RequireAll>
                # Must NOT be identified as a generic unidentified bot keyword
                Require expr "! req('User-Agent') =~ /(bot|spider|crawler|indexer|archiver|headless|scrap|preview|discovery)/i"
                
                # AND must NOT be an expensive script (accessibility for humans)
                Require expr "-z reqenv('expensive_script')"
            </RequireAll>
        </RequireAny>
    </RequireAll>
</Location>

This then generates apache log files which are picked up by fail2ban rules:

Fail2ban Jail Definitions (/etc/fail2ban/jail.d/apache-koha.conf)

[apache-koha-hard]
enabled  = true
port     = http,https
filter   = apache-koha-hard
logpath  = %(apache_access_log)s
# 24 hour ban for hard targets
bantime  = 86400
findtime = 600
maxretry = 3
# Full whitelist including major Croatian ISPs + CARNet
ignoreip = 10.111.0.0/16 127.0.0.1/8 193.198.212.0/22 161.53.0.0/16 193.198.0.0/16 109.60.0.0/17 188.129.76.0/22 188.129.80.0/22 188.252.128.0/17 188.252.196.0/22 31.217.0.0/19 31.217.32.0/19 31.217.36.0/22 31.217.48.0/20 46.188.224.0/19 78.0.0.0/15 83.131.0.0/16 86.32.0.0/15 89.172.0.0/16 93.136.0.0/14 94.253.128.0/17 95.168.96.0/19 95.178.160.0/19 212.15.160.0/19 141.136.128.0/17 31.147.0.0/16 31.45.128.0/17 37.244.128.0/17 78.134.128.0/17 82.193.192.0/19 88.207.0.0/17 94.250.128.0/18 109.227.0.0/18 5.39.128.0/19 5.43.160.0/19 80.80.48.0/20 85.114.32.0/19 89.17.0.0/19 89.201.0.0/17 176.10.240.0/20 89.164.0.0/16 141.138.0.0/18 213.191.128.0/19 213.202.64.0/19 178.160.0.0/17 195.29.0.0/16 213.147.96.0/19

[apache-shell-scanner]
enabled  = true
port     = http,https
filter   = apache-shell-scanner
logpath  = %(apache_access_log)s
# 7-day ban -- webshell scanners are always malicious
bantime  = 604800
findtime = 60
maxretry = 1
ignoreip = 10.111.0.0/16 127.0.0.1/8 193.198.212.0/22 161.53.0.0/16 193.198.0.0/16 109.60.0.0/17 188.129.76.0/22 188.129.80.0/22 188.252.128.0/17 188.252.196.0/22 31.217.0.0/19 31.217.32.0/19 31.217.36.0/22 31.217.48.0/20 46.188.224.0/19 78.0.0.0/15 83.131.0.0/16 86.32.0.0/15 89.172.0.0/16 93.136.0.0/14 94.253.128.0/17 95.168.96.0/19 95.178.160.0/19 212.15.160.0/19 141.136.128.0/17 31.147.0.0/16 31.45.128.0/17 37.244.128.0/17 78.134.128.0/17 82.193.192.0/19 88.207.0.0/17 94.250.128.0/18 109.227.0.0/18 5.39.128.0/19 5.43.160.0/19 80.80.48.0/20 85.114.32.0/19 89.17.0.0/19 89.201.0.0/17 176.10.240.0/20 89.164.0.0/16 141.138.0.0/18 213.191.128.0/19 213.202.64.0/19 178.160.0.0/17 195.29.0.0/16 213.147.96.0/19

[apache-koha]
enabled  = true
port     = http,https
filter   = apache-koha
logpath  = %(apache_access_log)s
bantime  = 3600
findtime = 600
maxretry = 100
ignoreip = 10.111.0.0/16 127.0.0.1/8 193.198.212.0/22 161.53.0.0/16 193.198.0.0/16 109.60.0.0/17 188.129.76.0/22 188.129.80.0/22 188.252.128.0/17 188.252.196.0/22 31.217.0.0/19 31.217.32.0/19 31.217.36.0/22 31.217.48.0/20 46.188.224.0/19 78.0.0.0/15 83.131.0.0/16 86.32.0.0/15 89.172.0.0/16 93.136.0.0/14 94.253.128.0/17 95.168.96.0/19 95.178.160.0/19 212.15.160.0/19 141.136.128.0/17 31.147.0.0/16 31.45.128.0/17 37.244.128.0/17 78.134.128.0/17 82.193.192.0/19 88.207.0.0/17 94.250.128.0/18 109.227.0.0/18 5.39.128.0/19 5.43.160.0/19 80.80.48.0/20 85.114.32.0/19 89.17.0.0/19 89.201.0.0/17 176.10.240.0/20 89.164.0.0/16 141.138.0.0/18 213.191.128.0/19 213.202.64.0/19 178.160.0.0/17 195.29.0.0/16 213.147.96.0/19

Fail2ban 403 Jail Definition (/etc/fail2ban/jail.d/apache-s403.conf)

[apache-s403]
enabled = true
port     = http,https
logpath  = %(apache_access_log)s
bantime  = 3600
maxretry = 5
ignoreip = 10.111.0.0/16 127.0.0.1/8 193.198.212.0/22 161.53.0.0/16 193.198.0.0/16 109.60.0.0/17 188.129.76.0/22 188.129.80.0/22 188.252.128.0/17 188.252.196.0/22 31.217.0.0/19 31.217.32.0/19 31.217.36.0/22 31.217.48.0/20 46.188.224.0/19 78.0.0.0/15 83.131.0.0/16 86.32.0.0/15 89.172.0.0/16 93.136.0.0/14 94.253.128.0/17 95.168.96.0/19 95.178.160.0/19 212.15.160.0/19 141.136.128.0/17 31.147.0.0/16 31.45.128.0/17 37.244.128.0/17 78.134.128.0/17 82.193.192.0/19 88.207.0.0/17 94.250.128.0/18 109.227.0.0/18 5.39.128.0/19 5.43.160.0/19 80.80.48.0/20 85.114.32.0/19 89.17.0.0/19 89.201.0.0/17 176.10.240.0/20 89.164.0.0/16 141.138.0.0/18 213.191.128.0/19 213.202.64.0/19 178.160.0.0/17 195.29.0.0/16 213.147.96.0/19

Fail2ban Filter Definitions

1. Hard Koha Targets (/etc/fail2ban/filter.d/apache-koha-hard.conf)

[Definition]

# FAST REACTION: Probes and Confirmed Malicious Signatures
# Designed for low maxretry (e.g. 1-3)

# 1. Target Subnets
failregex = ^\S+ (?=(?:43|47\.82|101\.47|116\.179|119\.249|150\.158|220\.181)\.)<HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/.*" \d+ \d+ "(?!https?://[^"]*(?:ffzg|unizg|rot13|bioetika|hpm)).*?" "(?!.*(?:Googlebot|bingbot|DuckDuckBot|archive\.org_bot)).*?"
# 2. Known Malicious User-Agents (no referer exception -- UA is UA regardless of referer)
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/.*" \d+ \d+ ".*?" ".*(GPTBot|ChatGPT-User|Sogou|Thinkbot|Applebot|YandexBot|OAI-SearchBot|ClaudeBot|TikTokSpider|Bytespider|PetalBot|AhrefsBot|DotBot|PerplexityBot|Barkrowler|megaindex).*"
# 3. Generic Bot Keywords (no referer exception -- bots faking local referers must not bypass)
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/.*" \d+ \d+ ".*?" ".*(bot|spider|crawler|indexer|archiver|headless|scrap|preview|discovery).*"
# 4. Malicious Probes (Exploits like PHP)
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /.*\.(php|env|git|sh|sql|asp|jsp).*"
# 5. Behavioral Probes
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/opac-[^"]*shelfbrowse_itemnumber=.*" \d+ \d+ "(?!https?://[^"]*(?:ffzg|unizg|rot13|bioetika|hpm)).*?" "(?!.*(?:Googlebot|bingbot|DuckDuckBot|archive\.org_bot)).*?"
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/[^"]*format=rss.*" \d+ \d+ "(?!https?://[^"]*(?:ffzg|unizg|rot13|bioetika|hpm)).*?" "(?!.*(?:Googlebot|bingbot|DuckDuckBot|archive\.org_bot)).*?"

2. Soft Koha Targets (/etc/fail2ban/filter.d/apache-koha.conf)

[Definition]

# SOFT FILTER: ALL OPAC scripts without local referer
# Designed for a high maxretry (e.g. 100) to allow human browsing

# Matches ALL opac scripts. Uses negative lookaheads to explicitly ignore legitimate 
# referers (ffzg, unizg, etc.) and search engine User-Agents. This avoids fail2ban 0.9.x
# ignoreregex parsing bugs.
failregex = ^\S+ <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/opac-[^ ]+\.pl.*" \d+ \d+ "(?!https?://[^"]*(?:ffzg|unizg|rot13|bioetika|hpm)).*?" "(?!.*(?:Googlebot|bingbot|DuckDuckBot|archive\.org_bot)).*?"

3. Zero-Tolerance Shell Scans (/etc/fail2ban/filter.d/apache-shell-scanner.conf)

[Definition]

# Webshell/exploit scanner detection -- zero tolerance
# Matches classic backdoor/RCE probe paths regardless of IP, UA, or referer.
# Designed for maxretry=1 (one hit = instant ban).

failregex =
# PHP webshell probes (backdoor filenames, wp-content exploits, xmlrpc)
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /.*(?:shell|webshell|wp_filemanager|eval-stdin|eval\.php|passthru|cmd\.php|c99\.php|r57\.php|b374k|wso\.php|alfa\.php|priv8|indoxploit|symlink|adminer\.php|phpinfo\.php|phpmyadmin|myphpadmin|pma/|/xmlrpc\.php|/wp-content/plugins/[^"]*\.php|/wp-includes/wlwmanifest|/wp-login\.php|/setup-config\.php|/install\.php).*"
# Dotfile/credential harvesting (.env, .git, backup files)
            ^\S+ <HOST> - - .* "(GET|POST|HEAD) /.*(?:\.env|\.git/config|backup\.sql|dump\.sql|db\.sql|\.aws/credentials|wp-config\.php|config\.php\.bak|settings\.php\.bak|web\.config|\.htpasswd).*"
# Common numeric/random PHP shell filenames (e.g. /a5.php, /wo.php, /gptsh.php)
            ^\S+ <HOST> - - .* "(GET|POST) /[a-z0-9_-]{2,12}\.php(?:\?[^"]{0,100})?" \d+ \d+ "-" "(?:-|[^"]{0,200})"

ignoreregex =
# Ignore known legitimate PHP apps on this server (only /cgi-bin/koha/ is legitimate)
            ^\S+ <HOST> - - .* "(GET|POST) /cgi-bin/koha/.*\.php.*"

4. 403 Forbidden Detection Filter (/etc/fail2ban/filter.d/apache-s403.conf)

[Definition]

# koha.ffzg.hr:443 150.109.24.245 - - [16/Sep/2024:04:36:05 +0200] "GET /cgi-bin/koha/opac-detail.pl?biblionumber=216663 HTTP/1.1" 200 16159 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" 0 769541
failregex = ^.* <HOST> - - .* "(GET|POST|HEAD) /cgi-bin/koha/.*" 403

ignoreregex =

Some of the rules are redundant (you might call them cruft or slop if you want a negative spin, or defense in depth if you want a positive one), but it serves us well, so you can pick-and-choose parts which might apply to your situation.

I have been doing this job for 30 years. I still love it as much as I did when I first saw the Internet and Linux on November 29, 1995, and got sucked into sysadmin tasks.

To me, it seems that AI agents have enabled me to keep my infrastructure changes in Git (as I always did, using etckeeper and local Git repositories on my machines) but in a code and text form that I can review and store for later reference and documentation.

In fact, documentation is underrated. It is useful for me, so I always kept local notes on my machines in the form of:

yyyy-mm-dd-topic-of-change.txt

This allowed for quick lookups using grep to see what I changed. I always made sure to include all commands and outputs, because this file was the result of testing changes and was used to deploy them to production.

Nowadays, I have a local Git repository with code and documentation in a much more verbose form.

I really like that. I didn't write that content myself, but I read every line of it.

Did my work change? In one sense, no, but the result is totally different.

This is not quite GitOps or DevOps, but it suits my workflow.

Did I get a speedup?

Yes, 10x on most tasks.

If I can think of a solution to a problem in a few seconds, and spend 10-30 seconds more typing it into my agent, the task is done.

Commands can be executed much faster, but I still need time to process output. That's why I love ds4 from antirez, which I run on my local Aimax machine.

Its reading speed is ~10 tok/s for generation, and modem-like (with a progress bar!) for tokenization at ~50 tok/s. This provides just enough time to read everything and make sure that I'm not generating AI slop.

Am I getting dumber?

No, in fact, I can skim-read up to 200 tok/s since I use AI as my second brain. If I see something with which I don't agree, it's time to stop the agent (or use /btw in Gemini agy) and steer it in the right direction.

But more often than not, I'm learning something that the agent knew and I didn't. In the last year, I learned as much as I did in the three years before that. It's amazing what the Internet can surface to you if you use the biggest agents.

I think Gemini has the best web search of all agents, and since the Google index has been in RAM since we last saw the "Cached" option in Google search, I suspect that search results in agents are best in agy. Agents still don't search Google as I do. I know that the secret of Google search is skip lists, so I start with the most probable word and add more words to filter the results. Agents are more verbose than that, but they are quite good.

Are we getting locked-in?

Yes and no. Local agents (like DeepSeek v4) are really as good as SOTA models for everything I tried. I'm not afraid that I'm burning dinosaurs in vain.

My local AI usage of 85W should be offset by solar energy in the future, so it's sustainable for the planet.

On the other hand, I did spend the equivalent of about 8 years of a Gemini subscription to acquire my AMD Ryzen AI Max+ 395 machine, but having something locally which works and doesn't come with strings attached was worth it to me.

Let's see how these thoughts stand up to the test of time, since this was written in May 2026, and who knows what the future will bring.

Antigravity CLI helper utility

As you have seen from my previous blog posts, I'm usually missing some functionality when using AI. Since google killed Gemini CLI and replaced it with Antigravity CLI, I was missing ability to see old sessions, grep through them or commit local brain files to git.

So, I wrote agy-explore which solves this problem for me.

When I started using AI for serious work, I had the same requirements as for any other projects: local backup of my files. To achieve this, I decided to mirror Google AI Studio session files from Google Drive using Rclone.

To view them, I wrote a Google AI Studio HTML viewer. For Gemini CLI, I wrote a similar viewer for .gemini/tmp/*/chats/session*.json session files.

I find having these files locally very useful for searching (Ctrl+F); you might too. You can also copy any part as markdown or html.