Skip to content

feat: better handing of serve configuration#100

Merged
dkaser merged 7 commits intotrunkfrom
feat/improved-serve
Feb 16, 2026
Merged

feat: better handing of serve configuration#100
dkaser merged 7 commits intotrunkfrom
feat/improved-serve

Conversation

@dkaser
Copy link
Collaborator

@dkaser dkaser commented Feb 16, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Improved WebGUI port synchronization and startup validation to avoid port conflicts when funnel is enabled.
    • Better detection and removal of stale funnel entries to prevent proxy mismatches.
  • Refactor

    • Per-host funnel port management now persists changes reliably and updates WebGUI port settings.
  • New Features

    • New warning-level notifications for funnel/port issues.

Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
@github-actions github-actions bot added the feat label Feb 16, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 16, 2026

Warning

Rate limit exceeded

@dkaser has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 27 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Refactors serve/funnel management: Introduces a ServeConfig class, moves funnel port logic out of Info, adds ServeConfig APIs for configure/remove/search/update, changes LocalAPI to return ServeConfig, and adds System.checkFunnelPort with watcher integrations.

Changes

Cohort / File(s) Summary
ServeConfig & Local API
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php, src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php
Added typed ServeConfig class with constructor accepting optional config and many funnel-related methods (configureFunnel, removeServeByPort, removeFunnel, resetFunnel, hasFunnel, saveWebguiPort, getWebguiPort, getFunnelPort, updateWebProxy, private search helper). LocalAPI::getServeConfig() now returns ServeConfig; resetServeConfig() removed.
Info / Translator handling
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php
Made Translator optional (?Translator $tr), removed $serve property and getFunnelPort() method; tr() now throws if translator missing.
System: validation & funnel sync
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php
checkServeConfig() now accepts Config param; added checkFunnelPort(Config) to detect/sync WebGUI funnel port mismatches and update ServeConfig; added NotificationType::WARNING; enhanced funnel reset/update flows.
Watcher & plugin Config usage
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php, src/usr/local/emhttp/plugins/tailscale/include/data/Config.php
Watcher now calls System::checkServeConfig($this->config) and System::checkFunnelPort($this->config). Plugin Config uses LocalAPI->getServeConfig()->getFunnelPort(hostname) for UI/port checks; per-host funnel management now uses ServeConfig methods and persists webgui port.
Miscellaneous
src/usr/local/emhttp/plugins/tailscale/include/data/...
Removed old reset path that cleared ServeConfig when port empty; replaced direct ServeConfig instantiation with LocalAPI::getServeConfig() interactions.

Sequence Diagram(s)

sequenceDiagram
    participant Watcher
    participant LocalAPI
    participant ServeConfig
    participant System

    Watcher->>LocalAPI: getServeConfig()
    LocalAPI->>ServeConfig: construct from API response
    LocalAPI-->>Watcher: ServeConfig

    Watcher->>System: checkServeConfig(Config)
    System->>ServeConfig: inspect TCP ports / conflicts
    alt conflict
        System->>ServeConfig: removeServeByPort(port)
        System->>LocalAPI: setServeConfig(ServeConfig)
        System->>System: restart service
    end

    Watcher->>System: checkFunnelPort(Config)
    System->>ServeConfig: getFunnelPort(hostname)
    ServeConfig-->>System: current funnel port
    alt mismatch with ident.cfg port
        System->>ServeConfig: updateWebProxy(hostAndPort, newTarget)
        System->>LocalAPI: setServeConfig(ServeConfig)
        ServeConfig->>ServeConfig: saveWebguiPort(port)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (9 files):

⚔️ plugin/plugin.json (content)
⚔️ plugin/tailscale-preview.plg (content)
⚔️ plugin/tailscale.plg (content)
⚔️ src/usr/local/emhttp/plugins/tailscale/include/data/Config.php (content)
⚔️ src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php (content)
⚔️ src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php (content)
⚔️ src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php (content)
⚔️ src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php (content)
⚔️ src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php (content)

These conflicts must be resolved before merging into trunk.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: better handing of serve configuration' is directly related to the main changes, which refactor serve configuration handling across multiple files and improve WebGUI port synchronization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/improved-serve

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php (1)

28-43: ⚠️ Potential issue | 🟠 Major

$this->tr remains typed as Translator (non-nullable) but may never be assigned.

When null is passed (e.g., new Info(null) in System.php), the property stays uninitialized. PHP 8.x will throw a TypeError if any code path later accesses $this->tr on such an instance — including the private tr() helper on line 67.

Currently the null call-sites only invoke getDNSName(), so this won't crash today, but it's a latent trap.

🛡️ Option A — declare the property nullable and guard usage
-    private Translator $tr;
+    private ?Translator $tr = null;

Then guard the tr() helper:

     private function tr(string $message): string
     {
+        if ($this->tr === null) {
+            throw new \RuntimeException("Translator not available");
+        }
         return $this->tr->tr($message);
     }
🛡️ Option B — keep it non-nullable, throw on null input when translation-dependent methods are needed

Keep the current typed property, but assign a no-op or throw early so the property is always initialized:

     public function __construct(?Translator $tr)
     {
+        if ($tr === null) {
+            // Only hostname-resolution use; translation not available
+        }
+        $this->tr = $tr ?? throw new \LogicException("Translator required");

This forces callers that don't need translation to use a different, lighter-weight approach (e.g., a static method or a separate class).

🤖 Fix all issues with AI agents
In `@src/usr/local/emhttp/plugins/tailscale/include/data/Config.php`:
- Around line 144-147: getServeConfig() is being called inside the foreach over
$funnelPorts, causing redundant local API requests; move the call out of the
loop by fetching $serveConfig = $localAPI->getServeConfig() once before
iterating $funnelPorts, call
$serveConfig->getFunnelPort($tailscaleInfo->getDNSName()) once to derive
$currentPort before or at the start of the loop, and then iterate $funnelPorts
using the already-fetched $serveConfig and $currentPort variables so no repeated
API calls occur.
🧹 Nitpick comments (6)
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php (1)

163-168: new Info(null) makes three API calls just to get the DNS name.

Info::__construct calls getStatus(), getPrefs(), and getTkaStatus() — only getDNSName() (which reads from $this->status) is actually needed here. The same pattern appears on line 434. Consider extracting hostname resolution from Info to avoid the overhead, or adding a lightweight static/factory method.

src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php (5)

87-107: Static analysis: $webConfig is unused in the foreach loop.

Only the key ($fqdn) is needed. Suppress with $_ or use the key-only pattern.

♻️ Minimal fix
-            foreach ($this->config->Web as $fqdn => $webConfig) {
+            foreach ($this->config->Web as $fqdn => $_webConfig) {

191-218: Static analysis: $enabled is unused in the foreach loop on line 200.

Same pattern as removeServeByPort — only the key is needed.

♻️ Minimal fix
-        foreach ($serveConfig->AllowFunnel as $hostAndPort => $enabled) {
+        foreach ($serveConfig->AllowFunnel as $hostAndPort => $_enabled) {

109-127: removeFunnel removes the TCP entry for the port, which is shared across all hostnames.

If multiple Web entries (e.g., host1:443 and host2:443) share the same TCP port, removing the funnel for one host will break TCP termination for the other. In practice, a single Tailscale node has one DNS name so this likely won't happen — but worth a note if multi-host scenarios are ever considered.


141-170: Port persistence via saveWebguiPort / getWebguiPort works correctly.

saveWebguiPort("") deletes the file, and getWebguiPort() defensively handles missing file, decode failures, and type mismatches. One minor note: file_put_contents can return false on failure — consider logging if the write fails, since a silent failure here would cause checkFunnelPort to skip future reconciliation.


172-189: getFunnelPort reads ident.cfg directly — this couples a model class to the filesystem.

This duplicates the ident.cfg parsing already done by callers (e.g., System::checkFunnelPort, Config.php funnel-port case). Consider accepting the port as a required parameter and dropping the fallback ident.cfg read, which would simplify testing and keep I/O in the caller.

Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (7)
src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php (1)

28-34: Consider a Null Object pattern instead of nullable translator.

With ?Translator, roughly half the public methods (getStatusInfo, getConnectionInfo, getDashboardInfo, getKeyExpirationWarning, getTailscaleLockWarning, getTailscaleNetbiosWarning) become runtime bombs when $tr is null. Callers have no compile-time signal about which methods are safe.

Two lighter alternatives:

  1. Null Object: Create a NullTranslator that returns the key as-is, so tr() never throws.
  2. Split the class: Extract a TailscaleData (no translator needed) and keep Info for display-oriented methods that require a translator.

Either approach removes the hidden runtime exception.

src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php (5)

87-107: removeServeByPort accepts int while the rest of the API uses string for ports.

configureFunnel, removeFunnel, and getFunnelPort all declare $port as string. This method declares it as int. Since stdClass property names are always strings and the config is built with string keys (from JSON or configureFunnel), passing an int here works by implicit cast but is inconsistent and could confuse callers.

Suggested fix
-    public function removeServeByPort(int $port): void
+    public function removeServeByPort(string $port): void
     {

141-151: saveWebguiPort and getWebguiPort are filesystem I/O on a class that otherwise wraps an in-memory config.

This is a cohesion concern — these two methods read/write /boot/config/plugins/tailscale/funnel.json but have no relationship to the $this->config state. They'd be more at home in a dedicated persistence helper or in Config/Utils. Not blocking, but worth considering if this class grows further.

Also, file_put_contents can fail (permissions, disk full). Currently failures are silently ignored.


172-189: getFunnelPort reads the filesystem (ident.cfg) as a side-effect.

When $port is null, this method parses /boot/config/ident.cfg to determine the expected target port. This couples the class to a system-specific file path and makes unit testing harder. Consider always requiring the caller to supply the port (the caller in Config.php already has $identCfg['PORT'] available).


191-218: Port extraction via explode(":", ...) breaks for IPv6 literal hosts.

Line 208 splits on : and expects exactly 2 parts. While Tailscale DNS names are FQDNs (not IPv6 literals), a defensive approach would be to use strrpos / substr to extract the port after the last colon, which also handles edge cases.

Suggested fix
-                        $parts = explode(":", strval($hostAndPort));
-                        if (count($parts) == 2 && is_numeric($parts[1])) {
-                            return strval($parts[1]);
-                        }
+                        $lastColon = strrpos($hostAndPort, ":");
+                        if ($lastColon !== false) {
+                            $portPart = substr($hostAndPort, $lastColon + 1);
+                            if (is_numeric($portPart)) {
+                                return $portPart;
+                            }
+                        }

220-225: updateWebProxy silently no-ops if the path doesn't exist.

This is reasonable for defensive coding, but callers have no way to know whether the update actually took effect. Consider returning a bool or logging a warning when the path isn't found, so misuse doesn't go unnoticed.

src/usr/local/emhttp/plugins/tailscale/include/data/Config.php (1)

351-354: ident.cfg is parsed twice — once here (Line 346) and again inside getFunnelPort.

getFunnelPort($hostname) without a second argument falls back to parsing /boot/config/ident.cfg internally. Since you already have $identCfg['PORT'] from Line 346, pass it explicitly to avoid the redundant filesystem read.

Suggested fix
-            $currentFunnelPort = $serveConfig->getFunnelPort($hostname);
+            $currentFunnelPort = $serveConfig->getFunnelPort($hostname, $identCfg['PORT']);

Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
Signed-off-by: Derek Kaser <11674153+dkaser@users.noreply.github.com>
@dkaser dkaser merged commit a0f1b34 into trunk Feb 16, 2026
5 checks passed
@dkaser dkaser deleted the feat/improved-serve branch February 16, 2026 04:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant