Skip to content

Commit 3e9e6f8

Browse files
authored
Merge pull request #113 from AnthonyGress/dev
v1.2.0
2 parents 0dfee45 + d027d75 commit 3e9e6f8

File tree

4 files changed

+130
-30
lines changed

4 files changed

+130
-30
lines changed

backend/src/routes/pihole-v6.route.ts

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ interface SessionInfo {
1616
// Store sessions by host+password hash to reuse sessions until they expire
1717
const sessionCache = new Map<string, SessionInfo>();
1818

19+
// Track request timing to prevent overwhelming Pi-hole with concurrent requests
20+
const requestTimestamps = new Map<string, number>();
21+
1922
// Generate a cache key from host and password (without storing the actual password)
2023
const getCacheKey = (baseUrl: string, password: string): string => {
2124
// Simple hash function - do not use the password directly as a key
@@ -24,6 +27,22 @@ const getCacheKey = (baseUrl: string, password: string): string => {
2427
return `${baseUrl}:${hash}`;
2528
};
2629

30+
// Helper function to add a small delay between requests to the same Pi-hole instance
31+
const addRequestDelay = async (baseUrl: string): Promise<void> => {
32+
const lastRequestTime = requestTimestamps.get(baseUrl) || 0;
33+
const now = Date.now();
34+
const timeSinceLastRequest = now - lastRequestTime;
35+
36+
// If the last request was less than 100ms ago, add a small delay
37+
if (timeSinceLastRequest < 100) {
38+
const delay = 100 - timeSinceLastRequest;
39+
await new Promise(resolve => setTimeout(resolve, delay));
40+
}
41+
42+
// Update the timestamp
43+
requestTimestamps.set(baseUrl, Date.now());
44+
};
45+
2746
// Clean expired sessions periodically
2847
setInterval(async () => {
2948
const now = Date.now();
@@ -65,6 +84,20 @@ setInterval(async () => {
6584
// Wait for all logout operations to complete
6685
await Promise.all(logoutPromises);
6786
}
87+
88+
// Clean up old request timestamps (older than 1 hour)
89+
const oneHourAgo = now - (60 * 60 * 1000);
90+
let cleanedTimestamps = 0;
91+
requestTimestamps.forEach((timestamp, key) => {
92+
if (timestamp < oneHourAgo) {
93+
requestTimestamps.delete(key);
94+
cleanedTimestamps++;
95+
}
96+
});
97+
98+
if (expiredCount > 0 || cleanedTimestamps > 0) {
99+
console.log(`Pi-hole cleanup: ${expiredCount} expired sessions, ${logoutCount} successful logouts, ${cleanedTimestamps} old timestamps cleaned`);
100+
}
68101
}, 60000); // Check every minute
69102

70103
// Helper function to validate and get itemId with better error message
@@ -108,6 +141,21 @@ const getPassword = (req: Request): string | null => {
108141
return password;
109142
};
110143

144+
/**
145+
* Helper function to check if an error is a connection/session related error that should trigger a retry
146+
*/
147+
const isConnectionError = (error: any): boolean => {
148+
return error.code === 'ECONNRESET' ||
149+
error.code === 'ECONNABORTED' ||
150+
error.code === 'ETIMEDOUT' ||
151+
error.message?.includes('socket hang up') ||
152+
error.message?.includes('ECONNRESET') ||
153+
error.message?.includes('ECONNABORTED') ||
154+
error.message?.includes('timeout') ||
155+
error.response?.status === 401 ||
156+
error.response?.status === 403;
157+
};
158+
111159
/**
112160
* Helper function to check if an error is a DNS resolution error
113161
*/
@@ -136,33 +184,43 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
136184
const now = Date.now();
137185

138186
if (cachedSession && cachedSession.expires > now) {
139-
// If the session is about to expire soon (within 30 seconds), don't use it
140-
// This avoids potential edge cases where the session might expire during the request
141-
if (cachedSession.expires - now > 30000) {
187+
// If the session is about to expire soon (within 60 seconds), don't use it
188+
// This gives us more buffer to avoid edge cases where sessions expire during requests
189+
if (cachedSession.expires - now > 60000) {
142190
return {
143191
sid: cachedSession.sid,
144192
csrf: cachedSession.csrf
145193
};
194+
} else {
195+
// Session is expiring soon, remove it from cache
196+
console.log('Pi-hole session expiring soon, removing from cache');
197+
sessionCache.delete(cacheKey);
146198
}
147199
}
148200

149201
try {
202+
console.log(`Authenticating with Pi-hole at ${baseUrl}`);
203+
150204
const response = await axios.post(
151205
`${baseUrl}/api/auth`,
152206
{ password },
153207
{
154208
headers: {
155209
'Content-Type': 'application/json'
156210
},
157-
timeout: 2000
211+
timeout: 10000 // Increased timeout for authentication
158212
}
159213
);
160214

161215
if (response.data?.session?.valid && response.data?.session?.sid) {
162216
// Calculate expiration based on validity period (in seconds) returned by the API
163217
// Default to 1800 seconds (30 minutes) if not provided, which is Pi-hole v6's default
164218
const validitySeconds = response.data.session.validity || 1800;
165-
const expiresAt = now + (validitySeconds * 1000);
219+
// Reduce the validity by 10% to ensure we refresh before actual expiration
220+
const adjustedValiditySeconds = Math.floor(validitySeconds * 0.9);
221+
const expiresAt = now + (adjustedValiditySeconds * 1000);
222+
223+
console.log(`Pi-hole session created, expires in ${adjustedValiditySeconds} seconds`);
166224

167225
// Store the session in cache
168226
const sessionInfo: SessionInfo = {
@@ -184,17 +242,19 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
184242
throw new Error('Authentication failed: Invalid or missing session information');
185243
} catch (error: any) {
186244
console.error('Pi-hole v6 authentication error:', error.message);
187-
console.error('Auth error details:', {
188-
status: error.response?.status,
189-
statusText: error.response?.statusText,
190-
data: error.response?.data,
191-
headers: error.response?.headers,
192-
stack: error.stack
193-
});
194245

195246
// Clear any cached session as it might be invalid
196247
sessionCache.delete(cacheKey);
197248

249+
// Enhanced error handling for connection issues
250+
if (error.message?.includes('socket hang up') || error.code === 'ECONNRESET') {
251+
throw {
252+
status: 503,
253+
message: 'Connection to Pi-hole failed (socket hang up). The Pi-hole server may be overloaded or experiencing network issues.',
254+
code: 'CONNECTION_ERROR'
255+
};
256+
}
257+
198258
// Check for rate limiting (429 Too Many Requests)
199259
if (isRateLimitError(error)) {
200260
throw {
@@ -222,6 +282,15 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
222282
};
223283
}
224284

285+
// Check for timeout errors
286+
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
287+
throw {
288+
status: 504,
289+
message: 'Authentication timeout. Pi-hole server may be slow to respond.',
290+
code: 'TIMEOUT_ERROR'
291+
};
292+
}
293+
225294
throw {
226295
status: error.response?.status || 500,
227296
message: error.response?.data?.error?.message || error.message || 'Authentication failed',
@@ -231,8 +300,8 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
231300
}
232301

233302
/**
234-
* Helper function to handle API calls with automatic retry on 401 errors
235-
* This centralizes the logic for handling expired sessions
303+
* Helper function to handle API calls with automatic retry on connection/session errors
304+
* This centralizes the logic for handling expired sessions and connection issues
236305
*/
237306
async function handleApiWith401Retry(
238307
baseUrl: string,
@@ -243,17 +312,20 @@ async function handleApiWith401Retry(
243312
retryAttempt = 0
244313
): Promise<any> {
245314
// Maximum retry attempts to prevent infinite loops
246-
const MAX_RETRIES = 1;
315+
const MAX_RETRIES = 2;
247316

248317
try {
318+
// Add a small delay to prevent overwhelming Pi-hole with concurrent requests
319+
await addRequestDelay(baseUrl);
320+
249321
// First authenticate to get a session
250322
const authInfo = await authenticatePihole(baseUrl, password);
251323

252-
// Prepare the request config
324+
// Prepare the request config with increased timeout for better reliability
253325
const config = {
254326
params: { sid: authInfo.sid },
255327
headers: { 'X-FTL-CSRF': authInfo.csrf, 'Content-Type': 'application/json' },
256-
timeout: 2000
328+
timeout: 5000 // Increased from 2000ms to 5000ms for better reliability
257329
};
258330

259331
// Make the API request based on the method
@@ -270,19 +342,30 @@ async function handleApiWith401Retry(
270342

271343
return response;
272344
} catch (error: any) {
273-
// If this is a 401 error and we haven't exceeded max retries, try to re-authenticate
274-
if (error.response?.status === 401 && retryAttempt < MAX_RETRIES) {
275-
console.log(`Received 401 during API call to ${endpoint}, clearing session and retrying...`);
345+
console.error(`Pi-hole API error on ${endpoint} (attempt ${retryAttempt + 1}):`, {
346+
message: error.message,
347+
code: error.code,
348+
status: error.response?.status,
349+
data: error.response?.data
350+
});
351+
352+
// If this is a connection error (including socket hang up) and we haven't exceeded max retries
353+
if (isConnectionError(error) && retryAttempt < MAX_RETRIES) {
354+
console.log(`Connection error detected on ${endpoint}, clearing session and retrying...`);
276355

277356
// Clear the cached session as it's likely invalid
278357
const cacheKey = getCacheKey(baseUrl, password);
279358
sessionCache.delete(cacheKey);
280359

360+
// Add a longer delay before retry to give Pi-hole time to recover
361+
const retryDelay = Math.min(2000 + (retryAttempt * 1000), 5000); // 2s, 3s, max 5s
362+
await new Promise(resolve => setTimeout(resolve, retryDelay));
363+
281364
// Retry the request with a fresh authentication
282365
return handleApiWith401Retry(baseUrl, password, endpoint, method, data, retryAttempt + 1);
283366
}
284367

285-
// If it's not a 401 or we've exceeded retries, throw the error
368+
// If it's not a retryable error or we've exceeded retries, throw the error
286369
throw error;
287370
}
288371
}

frontend/src/components/dashboard/base-items/widgets/PiholeWidget/PiholeWidget.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ export const PiholeWidget = (props: { config?: PiholeWidgetConfig; id?: string }
218218
setAuthFailed(true);
219219
setError('Bad Request: Invalid configuration or authentication data');
220220
return; // Exit early to avoid the stats request
221+
} else if (statusError.response?.status === 503 || statusError.message?.includes('socket hang up')) {
222+
// Handle connection errors specifically
223+
console.warn('Pi-hole connection error, will retry on next interval:', statusError.message);
224+
// Don't set error state immediately for connection issues, allow retry
225+
return; // Exit early but don't set permanent error
226+
} else if (statusError.response?.status === 504 || statusError.message?.includes('timeout')) {
227+
// Handle timeout errors
228+
console.warn('Pi-hole timeout error, will retry on next interval:', statusError.message);
229+
return; // Exit early but don't set permanent error
221230
}
222231
}
223232

@@ -273,6 +282,14 @@ export const PiholeWidget = (props: { config?: PiholeWidgetConfig; id?: string }
273282
} else if (err.response?.status === 400) {
274283
setAuthFailed(true);
275284
setError('Bad Request: Invalid configuration or authentication data');
285+
} else if (err.response?.status === 503 || err.message?.includes('socket hang up')) {
286+
// Handle connection errors - don't set permanent error, allow retry
287+
console.warn('Pi-hole connection error during stats fetch, will retry:', err.message);
288+
return; // Exit without setting error state
289+
} else if (err.response?.status === 504 || err.message?.includes('timeout')) {
290+
// Handle timeout errors - don't set permanent error, allow retry
291+
console.warn('Pi-hole timeout error during stats fetch, will retry:', err.message);
292+
return; // Exit without setting error state
276293
} else if (err.response?.status === 429) {
277294
// Check if this is a rate limit from our backend API
278295
if (err.response?.data?.error_source === 'labdash_api') {
@@ -287,11 +304,11 @@ export const PiholeWidget = (props: { config?: PiholeWidgetConfig; id?: string }
287304
setAuthFailed(true);
288305
setError('Too many requests to Pi-hole API. The default session expiration is 30 minutes. You can manually clear unused sessions or increase the max_sessions setting in Pi-hole.');
289306
} else if (err.message?.includes('Network Error') || err.message?.includes('timeout')) {
290-
// Network errors like timeouts or connection refused
291-
setAuthFailed(true); // Use authFailed to prevent further requests
292-
setError(`Connection failed: ${err.message}. Please check if Pi-hole is running.`);
307+
// Network errors like timeouts or connection refused - don't set permanent error for temporary issues
308+
console.warn('Pi-hole network error, will retry:', err.message);
309+
return; // Exit without setting permanent error state
293310
} else if (err.message) {
294-
setAuthFailed(true); // Use authFailed to prevent further requests for all error types
311+
setAuthFailed(true); // Use authFailed to prevent further requests for all other error types
295312
setError(err.message);
296313
} else {
297314
setAuthFailed(true);

frontend/src/components/forms/AddEditForm.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,10 +1211,10 @@ export const AddEditForm = ({ handleClose, existingItem, onSubmit }: Props) => {
12111211
validate: (value: string) => {
12121212
if (!value) return 'Page name is required';
12131213

1214-
// Check if it's only alphanumeric characters
1215-
const alphanumericRegex = /^[a-zA-Z0-9]+$/;
1216-
if (!alphanumericRegex.test(value)) {
1217-
return 'Page name can only contain letters and numbers';
1214+
// Check if it contains only alphanumeric characters and spaces
1215+
const allowedCharsRegex = /^[a-zA-Z0-9\s]+$/;
1216+
if (!allowedCharsRegex.test(value)) {
1217+
return 'Page name can only contain letters, numbers, and spaces';
12181218
}
12191219

12201220
// Check if it's the word "settings" (case-insensitive)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lab-dash",
3-
"version": "1.1.8",
3+
"version": "1.2.0",
44
"description": "This is an open-source user interface designed to manage your server and homelab",
55
"main": "index.js",
66
"scripts": {

0 commit comments

Comments
 (0)