Compare commits

...
Author SHA1 Message Date
jaredandClaude Sonnet 5 74544ac5b4 Merge development into main: LDAP avatar lookups now use LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 30s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m21s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:59:21 -04:00
jaredandClaude Sonnet 5 863f84f37e Switch LDAP avatar lookups to LDAPS (#95)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 28s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m56s
Lint / Deploy (push) Successful in 3s
api/user_avatar.php connected via ldap://$ldapHost:$ldapPort — never
ldaps://, and there was no ldap_start_tls() call anywhere in the
codebase. LDAP_BIND_PW was sent over the wire unencrypted on every
avatar fetch.

Switched to ldaps://, and changed LDAP_HOST/LDAP_PORT's defaults to
ldap.lotusguild.org:6360 (lldap's LDAPS listener) instead of the bare
IP on port 3890 (plaintext). PHP's ldap extension verifies the server
cert's hostname by default, so a bare IP won't validate against the
LDAPS cert (issued for *.lotusguild.org) — LDAP_HOST has to be a
hostname the cert covers. This is deliberately not configurable back to
plaintext ldap://.

Infra change (pve-infra, separate repo/commit): added a Pi-hole
split-horizon override so ldap.lotusguild.org resolves internally to
the real LDAP server's LAN IP — its existing public DNS record points
elsewhere (an unrelated host), and there was no internal-only DNS entry
for it before this.

Verified against the real lldap server (pct 147, LDAPS on 6360, a live
Let's Encrypt *.lotusguild.org cert): confirmed the Pi-hole override
resolves correctly from hosts using it as their resolver, then ran the
exact ldap_connect/ldap_bind sequence via `php -r` directly on the
production tinker_tickets host (10.10.10.45) with a deliberately wrong
bind password — got "Invalid credentials" (a real LDAP protocol
response), not a transport/TLS error, proving the full connect + TLS
handshake + hostname verification + bind path works end-to-end in the
actual deployment environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-12 00:56:00 -04:00
jaredandClaude Sonnet 5 b73a4c792c Merge development into main: dashboard status chart excludes Closed (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:26:37 -04:00
jaredandClaude Sonnet 5 78ee5fdf48 Exclude Closed tickets from the dashboard status breakdown chart (#110)
Lint / PHP (phpcs PSR-12) (push) Successful in 31s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 31s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 6s
StatsModel::getAllStats()'s by_status breakdown grouped every status
including Closed, unlike by_priority and by_category which already
filter status != 'Closed'. Closed tickets accumulate indefinitely, so
over time the status donut chart's Closed slice comes to dominate it,
squeezing Open/Pending/In Progress down to barely-visible slivers —
exactly the breakdown the chart exists to show at a glance.

Filtered by_status the same way the other two breakdowns already are.
The separate open_tickets/closed_tickets KPI counts are unaffected (a
different query); Closed just no longer appears as a chart segment.

Note: this issue was labeled 'invalid' with no explanation in the body
or comments — checked and it's Gitea's generic stock label (description
"Something is wrong"), not a documented decision that the request itself
was wrong. The described problem is real and reproducible in the code,
so implementing it as filed.

Verified against real MariaDB: seeded 3 open-ish tickets (Open, Pending,
In Progress) and 3 Closed. by_status now returns only the 3 active
statuses; open_tickets/closed_tickets KPI counts are unchanged at 3/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
2026-09-11 22:23:33 -04:00
5 changed files with 30 additions and 10 deletions
+7 -3
View File
@@ -74,10 +74,14 @@ TRUSTED_PROXIES=
; Timezone (default: America/New_York) ; Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York
; LDAP / lldap (for user avatar lookups) ; LDAP / lldap (for user avatar lookups). Connects over LDAPS — 6360 is
; lldap's default LDAPS port, NOT its plaintext port (3890), since
; LDAP_BIND_PW below would otherwise go over the wire unencrypted.
; LDAP_HOST must be a hostname matching the LDAPS cert (TLS hostname
; verification is not skipped), not a bare IP.
LDAP_ENABLED=true LDAP_ENABLED=true
LDAP_HOST=10.10.10.39 LDAP_HOST=ldap.lotusguild.org
LDAP_PORT=3890 LDAP_PORT=6360
LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com" LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com"
LDAP_BIND_PW= LDAP_BIND_PW=
LDAP_BASE_DN="dc=example,dc=com" LDAP_BASE_DN="dc=example,dc=com"
+4 -1
View File
@@ -113,7 +113,10 @@ $avatarData = null;
$ldapQueryOk = false; // true only if the LDAP lookup completed without error $ldapQueryOk = false; // true only if the LDAP lookup completed without error
try { try {
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort"); // LDAPS, not plain ldap:// — LDAP_BIND_PW is sent during ldap_bind() below,
// and lldap's plaintext port (3890) would put it on the wire unencrypted.
// lldap's LDAPS listener defaults to port 6360 (see config.php).
$ldap = @ldap_connect("ldaps://$ldapHost:$ldapPort");
if (!$ldap) { if (!$ldap) {
throw new RuntimeException("ldap_connect failed"); throw new RuntimeException("ldap_connect failed");
} }
+13 -3
View File
@@ -154,9 +154,19 @@ $GLOBALS['config'] = [
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below 'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups) // LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39', // (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890), // its plaintext port 3890. The bind password must never go over the
// wire unencrypted, so this is not configurable back to a plaintext
// ldap:// connection.
//
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
// hostname by default and a mismatch fails the connection. Pi-hole has a
// split-horizon override so ldap.lotusguild.org resolves internally to
// the real LDAP server IP (its public DNS record points elsewhere).
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com', 'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '', 'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com', 'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
+1 -1
View File
@@ -148,7 +148,7 @@ class StatsModel
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
UNION ALL UNION ALL
SELECT 'status' as type, status as label, COUNT(*) as count SELECT 'status' as type, status as label, COUNT(*) as count
FROM tickets t WHERE ($visSQL) GROUP BY status FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY status
UNION ALL UNION ALL
SELECT 'category' as type, category as label, COUNT(*) as count SELECT 'category' as type, category as label, COUNT(*) as count
FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category"; FROM tickets t WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
+5 -2
View File
@@ -280,8 +280,11 @@ include __DIR__ . '/layout_header.php';
// default: with no `status` param the controller falls back to the viewer's // default: with no `status` param the controller falls back to the viewer's
// default_status_filters preference, which can be anything, so the resulting // default_status_filters preference, which can be anything, so the resulting
// list would not necessarily match what the chart counted. StatsModel builds // list would not necessarily match what the chart counted. StatsModel builds
// by_priority and by_category with `status != 'Closed'`, while by_status spans // by_priority, by_status, and by_category all with `status != 'Closed'`
// every status — so only the priority and category charts pin the open set. // closed tickets accumulate indefinitely and would otherwise dominate every
// breakdown over time — so chartPriority/chartCategory pin the open set
// explicitly, while chartStatus's clicked label is itself already one of
// the non-Closed statuses.
function openStatuses() { function openStatuses() {
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed']; var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
return all.filter(function(s) { return s !== 'Closed'; }).join(','); return all.filter(function(s) { return s !== 'Closed'; }).join(',');