Improve settings file lock handling and add admin file endpoint

This commit is contained in:
Anthony Stirling 2025-07-28 11:34:52 +01:00
parent 8e3b4ea08d
commit 8132f230ef
3 changed files with 388 additions and 55 deletions

View File

@ -25,7 +25,6 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -172,17 +171,22 @@ public class ApplicationProperties {
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@ToString.Exclude
@JsonProperty("idpMetadataUri")
private String idpMetadataUri;
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
private String idpIssuer;
@JsonProperty("idpCert")
private String idpCert;
@ToString.Exclude
@JsonProperty("privateKey")
private String privateKey;
@ToString.Exclude
@JsonProperty("spCert")
private String spCert;
@ -338,8 +342,10 @@ public class ApplicationProperties {
public static class TempFileManagement {
@JsonProperty("baseTmpDir")
private String baseTmpDir = "";
@JsonProperty("libreofficeDir")
private String libreofficeDir = "";
private String systemTempDir = "";
private String prefix = "stirling-pdf-";
private long maxAgeHours = 24;
@ -632,16 +638,22 @@ public class ApplicationProperties {
public static class TimeoutMinutes {
@JsonProperty("libreOfficetimeoutMinutes")
private long libreOfficeTimeoutMinutes;
@JsonProperty("pdfToHtmltimeoutMinutes")
private long pdfToHtmlTimeoutMinutes;
@JsonProperty("pythonOpenCvtimeoutMinutes")
private long pythonOpenCvTimeoutMinutes;
@JsonProperty("weasyPrinttimeoutMinutes")
private long weasyPrintTimeoutMinutes;
@JsonProperty("installApptimeoutMinutes")
private long installAppTimeoutMinutes;
@JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes;
private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;

View File

@ -4,9 +4,11 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.management.ManagementFactory;
import java.net.*;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
@ -17,6 +19,7 @@ import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@ -48,7 +51,15 @@ public class GeneralUtils {
// Lock timeout configuration
private static final long LOCK_TIMEOUT_SECONDS = 30; // Maximum time to wait for locks
private static final long FILE_LOCK_TIMEOUT_MS = 5000; // File lock timeout
private static final long FILE_LOCK_TIMEOUT_MS = 1000; // File lock timeout
// Track active file locks for diagnostics
private static final ConcurrentHashMap<String, String> activeLocks = new ConcurrentHashMap<>();
// Configuration flag for force bypass mode
private static final boolean FORCE_BYPASS_EXTERNAL_LOCKS =
Boolean.parseBoolean(
System.getProperty("stirling.settings.force-bypass-locks", "true"));
// Initialize settings hash on first access
static {
@ -443,23 +454,53 @@ public class GeneralUtils {
}
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
// Attempt file locking with timeout and retry logic
FileLock fileLock = null;
// Get current thread and process info for diagnostics
String currentThread = Thread.currentThread().getName();
String currentProcess = ManagementFactory.getRuntimeMXBean().getName();
String lockAttemptInfo =
String.format(
"Thread:%s Process:%s Setting:%s", currentThread, currentProcess, key);
log.debug(
"Attempting to acquire file lock for setting '{}' from {}",
key,
lockAttemptInfo);
// Check what locks are currently active
if (!activeLocks.isEmpty()) {
log.debug("Active locks detected: {}", activeLocks);
}
// Attempt file locking with proper retry logic
long startTime = System.currentTimeMillis();
int retryCount = 0;
final int maxRetries = (int) (FILE_LOCK_TIMEOUT_MS / 100); // 100ms intervals
while (fileLock == null
&& (System.currentTimeMillis() - startTime) < FILE_LOCK_TIMEOUT_MS) {
try (FileChannel channel =
FileChannel.open(
settingsPath,
StandardOpenOption.READ,
StandardOpenOption.WRITE,
StandardOpenOption.CREATE)) {
while ((System.currentTimeMillis() - startTime) < FILE_LOCK_TIMEOUT_MS) {
FileChannel channel = null;
FileLock fileLock = null;
// Try non-blocking lock first
try {
// Open channel and keep it open during lock attempts
channel =
FileChannel.open(
settingsPath,
StandardOpenOption.READ,
StandardOpenOption.WRITE,
StandardOpenOption.CREATE);
// Try to acquire exclusive lock
fileLock = channel.tryLock();
if (fileLock != null) {
// Successfully acquired lock - track it for diagnostics
String lockInfo =
String.format(
"%s acquired at %d",
lockAttemptInfo, System.currentTimeMillis());
activeLocks.put(settingsPath.toString(), lockInfo);
// Successfully acquired lock - perform the update
try {
// Validate that we can actually read/write to detect stale locks
if (!Files.isWritable(settingsPath)) {
@ -467,15 +508,6 @@ public class GeneralUtils {
"Settings file is not writable - permissions issue");
}
// Check for concurrent modifications
String currentHash = calculateSettingsHash();
if (lastSettingsHash != null && !lastSettingsHash.equals(currentHash)) {
log.info(
"Settings file was modified externally for key: {} - updating hash",
key);
lastSettingsHash = currentHash;
}
// Perform the actual update
String[] keyArray = key.split("\\.");
YamlHelper settingsYaml = new YamlHelper(settingsPath);
@ -483,52 +515,166 @@ public class GeneralUtils {
settingsYaml.saveOverride(settingsPath);
// Update hash after successful write
lastSettingsHash = calculateSettingsHash();
lastSettingsHash = null; // Will be recalculated on next access
log.debug("Successfully updated setting: {} = {}", key, newValue);
log.debug(
"Successfully updated setting: {} = {} (attempt {}) by {}",
key,
newValue,
retryCount + 1,
lockAttemptInfo);
return; // Success - exit method
} finally {
// Remove from active locks tracking
activeLocks.remove(settingsPath.toString());
// Ensure file lock is always released
if (fileLock != null && fileLock.isValid()) {
try {
fileLock.release();
} catch (IOException e) {
log.warn(
"Failed to release file lock for setting {}: {}",
key,
e.getMessage());
}
if (fileLock.isValid()) {
fileLock.release();
}
}
} else {
// Lock not available, wait briefly before retry
Thread.sleep(100);
// Lock not available - log diagnostic info
retryCount++;
log.debug(
"File lock not available for setting '{}', attempt {} of {} by {}. Active locks: {}",
key,
retryCount,
maxRetries,
lockAttemptInfo,
activeLocks.isEmpty() ? "none" : activeLocks);
// Wait before retry with exponential backoff (100ms, 150ms, 200ms, etc.)
long waitTime = Math.min(100 + (retryCount * 50), 500);
Thread.sleep(waitTime);
}
} catch (IOException e) {
if (fileLock != null && fileLock.isValid()) {
try {
fileLock.release();
} catch (IOException releaseError) {
log.warn(
"Failed to release file lock after error: {}",
releaseError.getMessage());
}
} catch (OverlappingFileLockException e) {
// This specific exception means another thread in the same JVM has the lock
retryCount++;
log.debug(
"Overlapping file lock detected for setting '{}', attempt {} of {} by {}. Another thread in this JVM has the lock. Active locks: {}",
key,
retryCount,
maxRetries,
lockAttemptInfo,
activeLocks);
try {
// Wait before retry with exponential backoff
long waitTime = Math.min(100 + (retryCount * 50), 500);
Thread.sleep(waitTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for file lock retry", ie);
}
} catch (IOException e) {
// If this is a specific lock contention error, continue retrying
if (e.getMessage() != null
&& (e.getMessage().contains("locked")
|| e.getMessage().contains("another process")
|| e.getMessage().contains("sharing violation"))) {
retryCount++;
log.debug(
"File lock contention for setting '{}', attempt {} of {} by {}. IOException: {}. Active locks: {}",
key,
retryCount,
maxRetries,
lockAttemptInfo,
e.getMessage(),
activeLocks);
try {
// Wait before retry with exponential backoff
long waitTime = Math.min(100 + (retryCount * 50), 500);
Thread.sleep(waitTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException(
"Interrupted while waiting for file lock retry", ie);
}
} else {
// Different type of IOException - don't retry
throw e;
}
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for file lock", e);
} finally {
// Clean up resources
if (fileLock != null && fileLock.isValid()) {
try {
fileLock.release();
} catch (IOException e) {
log.warn(
"Failed to release file lock for setting {}: {}",
key,
e.getMessage());
}
}
if (channel != null && channel.isOpen()) {
try {
channel.close();
} catch (IOException e) {
log.warn(
"Failed to close file channel for setting {}: {}",
key,
e.getMessage());
}
}
}
}
// If we get here, we couldn't acquire the file lock within timeout
// If no internal locks are active, this is likely an external system lock
// Try one final force write attempt if bypass is enabled
if (FORCE_BYPASS_EXTERNAL_LOCKS && activeLocks.isEmpty()) {
log.debug(
"No internal locks detected - attempting force write bypass for setting '{}' due to external system lock (bypass enabled)",
key);
try {
// Attempt direct file write without locking
String[] keyArray = key.split("\\.");
YamlHelper settingsYaml = new YamlHelper(settingsPath);
settingsYaml.updateValue(Arrays.asList(keyArray), newValue);
settingsYaml.saveOverride(settingsPath);
// Update hash after successful write
lastSettingsHash = null;
log.debug(
"Force write bypass successful for setting '{}' = {} (external system lock detected)",
key,
newValue);
return; // Success
} catch (Exception forceWriteException) {
log.error(
"Force write bypass failed for setting '{}': {}",
key,
forceWriteException.getMessage());
// Fall through to original exception
}
} else if (!FORCE_BYPASS_EXTERNAL_LOCKS && activeLocks.isEmpty()) {
log.debug(
"External system lock detected for setting '{}' but force bypass is disabled (use -Dstirling.settings.force-bypass-locks=true to enable)",
key);
}
throw new IOException(
String.format(
"Could not acquire file lock for setting '%s' within %d ms. "
+ "The settings file may be locked by another process or there may be file system issues.",
key, FILE_LOCK_TIMEOUT_MS));
"Could not acquire file lock for setting '%s' within %d ms by %s. "
+ "The settings file may be locked by another process or there may be file system issues. "
+ "Final active locks: %s. Force bypass %s",
key,
FILE_LOCK_TIMEOUT_MS,
lockAttemptInfo,
activeLocks,
activeLocks.isEmpty()
? "attempted but failed"
: "not attempted (internal locks detected)"));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@ -542,6 +688,17 @@ public class GeneralUtils {
} finally {
if (lockAcquired) {
settingsLock.writeLock().unlock();
// Recalculate hash after releasing the write lock
try {
if (lastSettingsHash == null) {
lastSettingsHash = calculateSettingsHash();
log.debug("Updated settings hash after write operation");
}
} catch (Exception e) {
log.warn("Failed to update settings hash after write: {}", e.getMessage());
lastSettingsHash = "";
}
}
}
}
@ -557,15 +714,133 @@ public class GeneralUtils {
return "";
}
byte[] fileBytes = Files.readAllBytes(settingsPath);
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(fileBytes);
boolean lockAcquired = false;
try {
lockAcquired = settingsLock.readLock().tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!lockAcquired) {
throw new IOException(
String.format(
"Could not acquire read lock for settings hash calculation within %d seconds. "
+ "System may be under heavy load or there may be a deadlock.",
LOCK_TIMEOUT_SECONDS));
}
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
// Use OS-level file locking with proper retry logic
long startTime = System.currentTimeMillis();
int retryCount = 0;
final int maxRetries = (int) (FILE_LOCK_TIMEOUT_MS / 100); // 100ms intervals
while ((System.currentTimeMillis() - startTime) < FILE_LOCK_TIMEOUT_MS) {
FileChannel channel = null;
FileLock fileLock = null;
try {
// Open channel and keep it open during lock attempts
channel = FileChannel.open(settingsPath, StandardOpenOption.READ);
// Try to acquire shared lock for reading
fileLock = channel.tryLock(0L, Long.MAX_VALUE, true);
if (fileLock != null) {
// Successfully acquired lock - calculate hash
try {
byte[] fileBytes = Files.readAllBytes(settingsPath);
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(fileBytes);
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
log.debug(
"Successfully calculated settings hash (attempt {})",
retryCount + 1);
return sb.toString();
} finally {
fileLock.release();
}
} else {
// Lock not available - log and retry
retryCount++;
log.debug(
"File lock not available for hash calculation, attempt {} of {}",
retryCount,
maxRetries);
// Wait before retry with exponential backoff
long waitTime = Math.min(100 + (retryCount * 50), 500);
Thread.sleep(waitTime);
}
} catch (IOException e) {
// If this is a specific lock contention error, continue retrying
if (e.getMessage() != null
&& (e.getMessage().contains("locked")
|| e.getMessage().contains("another process")
|| e.getMessage().contains("sharing violation"))) {
retryCount++;
log.debug(
"File lock contention for hash calculation, attempt {} of {}: {}",
retryCount,
maxRetries,
e.getMessage());
try {
// Wait before retry with exponential backoff
long waitTime = Math.min(100 + (retryCount * 50), 500);
Thread.sleep(waitTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException(
"Interrupted while waiting for file lock retry", ie);
}
} else {
// Different type of IOException - don't retry
throw e;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for file lock", e);
} finally {
// Clean up resources
if (fileLock != null && fileLock.isValid()) {
try {
fileLock.release();
} catch (IOException e) {
log.warn(
"Failed to release file lock for hash calculation: {}",
e.getMessage());
}
}
if (channel != null && channel.isOpen()) {
try {
channel.close();
} catch (IOException e) {
log.warn(
"Failed to close file channel for hash calculation: {}",
e.getMessage());
}
}
}
}
// If we couldn't get the file lock, throw an exception
throw new IOException(
String.format(
"Could not acquire file lock for settings hash calculation within %d ms. "
+ "The settings file may be locked by another process.",
FILE_LOCK_TIMEOUT_MS));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(
"Interrupted while waiting for settings read lock for hash calculation", e);
} finally {
if (lockAcquired) {
settingsLock.readLock().unlock();
}
}
return sb.toString();
}
/**

View File

@ -1,6 +1,9 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.regex.Pattern;
@ -27,6 +30,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
@ -60,6 +64,48 @@ public class AdminSettingsController {
return ResponseEntity.ok(applicationProperties);
}
@GetMapping("/file")
@Operation(
summary = "Get settings file content",
description =
"Retrieve the raw settings.yml file content showing the latest saved values (after restart). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Settings file retrieved successfully"),
@ApiResponse(responseCode = "404", description = "Settings file not found"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to read settings file")
})
public ResponseEntity<?> getSettingsFile() {
try {
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
if (!Files.exists(settingsPath)) {
return ResponseEntity.notFound().build();
}
String fileContent = Files.readString(settingsPath);
log.debug("Admin requested settings file content");
// Return as JSON with the file content
Map<String, String> response =
Map.of("filePath", settingsPath.toString(), "content", fileContent);
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to read settings file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to read settings file: " + e.getMessage());
} catch (Exception e) {
log.error("Unexpected error reading settings file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Unexpected error reading settings file");
}
}
@PutMapping
@Operation(
summary = "Update application settings (delta updates)",