diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java index 738cde8e6..e5c6488eb 100644 --- a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -43,26 +43,45 @@ public class JarPathUtil { } /** - * Gets the path to the restart-helper.jar file Expected to be in the same directory as the main - * JAR + * Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same + * directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar + * (development build) 3. app/common/build/libs/restart-helper.jar (multi-module build) * * @return Path to restart-helper.jar, or null if not found */ public static Path restartHelperJar() { Path appJar = currentJar(); - if (appJar == null) { - return null; + + // Define possible locations to check (in order of preference) + Path[] possibleLocations = new Path[4]; + + // Location 1: Same directory as main JAR (production) + if (appJar != null) { + possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar"); } - Path helperJar = appJar.getParent().resolve("restart-helper.jar"); + // Location 2: ./build/libs/ (development build) + possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath(); - if (Files.isRegularFile(helperJar)) { - log.debug("Restart helper JAR located at: {}", helperJar); - return helperJar; - } else { - log.warn("Restart helper JAR not found at: {}", helperJar); - return null; + // Location 3: app/common/build/libs/ (multi-module build) + possibleLocations[2] = + Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath(); + + // Location 4: Current working directory + possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath(); + + // Check each location + for (Path location : possibleLocations) { + if (location != null && Files.isRegularFile(location)) { + log.info("Restart helper JAR found at: {}", location); + return location; + } else if (location != null) { + log.debug("Restart helper JAR not found at: {}", location); + } } + + log.warn("Restart helper JAR not found in any expected location"); + return null; } /** diff --git a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java index 4de2bd597..66e097fdc 100644 --- a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java +++ b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java @@ -10,6 +10,7 @@ import java.util.Arrays; import java.util.Deque; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -135,6 +136,17 @@ public class YamlHelper { } else if ("true".equals(newValue) || "false".equals(newValue)) { newValueNode = new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN); + } else if (newValue instanceof Map map) { + // Handle Map objects - convert to MappingNode + List mapTuples = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + ScalarNode mapKeyNode = + new ScalarNode( + Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN); + Node mapValueNode = convertValueToNode(entry.getValue()); + mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode)); + } + newValueNode = new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK); } else if (newValue instanceof List list) { List sequenceNodes = new ArrayList<>(); for (Object item : list) { @@ -458,6 +470,43 @@ public class YamlHelper { return isInteger(object) || isShort(object) || isByte(object) || isLong(object); } + /** + * Converts a Java value to a YAML Node. + * + * @param value The value to convert. + * @return The corresponding YAML Node. + */ + private Node convertValueToNode(Object value) { + if (value == null) { + return new ScalarNode(Tag.NULL, "null", ScalarStyle.PLAIN); + } else if (isAnyInteger(value)) { + return new ScalarNode(Tag.INT, String.valueOf(value), ScalarStyle.PLAIN); + } else if (isFloat(value)) { + Object floatValue = Float.valueOf(String.valueOf(value)); + return new ScalarNode(Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN); + } else if (value instanceof Boolean || "true".equals(value) || "false".equals(value)) { + return new ScalarNode(Tag.BOOL, String.valueOf(value), ScalarStyle.PLAIN); + } else if (value instanceof Map map) { + // Recursively handle nested maps + List mapTuples = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + ScalarNode mapKeyNode = + new ScalarNode(Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN); + Node mapValueNode = convertValueToNode(entry.getValue()); + mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode)); + } + return new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK); + } else if (value instanceof List list) { + List sequenceNodes = new ArrayList<>(); + for (Object item : list) { + sequenceNodes.add(convertValueToNode(item)); + } + return new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW); + } else { + return new ScalarNode(Tag.STR, String.valueOf(value), ScalarStyle.PLAIN); + } + } + /** * Copies comments from an old node to a new one. * diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java index 4a6e7ad65..136bec9e5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java @@ -17,11 +17,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.service.UserLicenseSettingsService; @ExtendWith(MockitoExtension.class) class LicenseKeyCheckerTest { @Mock private KeygenLicenseVerifier verifier; + @Mock private UserLicenseSettingsService userLicenseSettingsService; @Test void premiumDisabled_skipsVerification() { @@ -29,7 +31,9 @@ class LicenseKeyCheckerTest { props.getPremium().setEnabled(false); props.getPremium().setKey("dummy"); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult()); verifyNoInteractions(verifier); @@ -42,7 +46,9 @@ class LicenseKeyCheckerTest { props.getPremium().setKey("abc"); when(verifier.verifyLicense("abc")).thenReturn(License.PRO); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult()); verify(verifier).verifyLicense("abc"); @@ -58,7 +64,9 @@ class LicenseKeyCheckerTest { props.getPremium().setKey("file:" + file.toString()); when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.ENTERPRISE, checker.getPremiumLicenseEnabledResult()); verify(verifier).verifyLicense("filekey"); @@ -71,7 +79,9 @@ class LicenseKeyCheckerTest { props.getPremium().setEnabled(true); props.getPremium().setKey("file:" + file.toString()); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult()); verifyNoInteractions(verifier); diff --git a/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx index 9d51ecc99..38624696b 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx @@ -67,32 +67,35 @@ export default function AdminSecuritySection() { const premiumData = premiumResponse.data || {}; const systemData = systemResponse.data || {}; + console.log('[AdminSecuritySection] Raw backend data:'); + console.log('Security:', JSON.parse(JSON.stringify(securityData))); + console.log('Premium:', JSON.parse(JSON.stringify(premiumData))); + console.log('System:', JSON.parse(JSON.stringify(systemData))); + const { _pending: securityPending, ...securityActive } = securityData; const { _pending: premiumPending, ...premiumActive } = premiumData; const { _pending: systemPending, ...systemActive } = systemData; + console.log('[AdminSecuritySection] Extracted pending blocks:', { + securityPending: JSON.parse(JSON.stringify(securityPending || {})), + premiumPending: JSON.parse(JSON.stringify(premiumPending || {})), + systemPending: JSON.parse(JSON.stringify(systemPending || {})) + }); + const combined: any = { - ...securityActive, - audit: premiumActive.enterpriseFeatures?.audit || { - enabled: false, - level: 2, - retentionDays: 90 - }, - html: systemActive.html || { - urlSecurity: { - enabled: true, - level: 'MEDIUM', - allowedDomains: [], - blockedDomains: [], - internalTlds: ['.local', '.internal', '.corp', '.home'], - blockPrivateNetworks: true, - blockLocalhost: true, - blockLinkLocal: true, - blockCloudMetadata: true - } - } + ...securityActive }; + // Only add audit if it exists (don't create defaults) + if (premiumActive.enterpriseFeatures?.audit) { + combined.audit = premiumActive.enterpriseFeatures.audit; + } + + // Only add html if it exists (don't create defaults) + if (systemActive.html) { + combined.html = systemActive.html; + } + // Merge all _pending blocks const mergedPending: any = {}; if (securityPending) { @@ -115,11 +118,25 @@ export default function AdminSecuritySection() { const { audit, html, ...securitySettings } = settings; const deltaSettings: Record = { + // Security settings + 'security.enableLogin': securitySettings.enableLogin, + 'security.csrfDisabled': securitySettings.csrfDisabled, + 'security.loginMethod': securitySettings.loginMethod, + 'security.loginAttemptCount': securitySettings.loginAttemptCount, + 'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes, + // JWT settings + 'security.jwt.persistence': securitySettings.jwt?.persistence, + 'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation, + 'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup, + 'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays, + 'security.jwt.secureCookie': securitySettings.jwt?.secureCookie, + // Premium audit settings 'premium.enterpriseFeatures.audit.enabled': audit?.enabled, 'premium.enterpriseFeatures.audit.level': audit?.level, 'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays }; + // System HTML settings if (html?.urlSecurity) { deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled; deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level; @@ -133,7 +150,7 @@ export default function AdminSecuritySection() { } return { - sectionData: securitySettings, + sectionData: {}, deltaSettings }; } @@ -217,7 +234,12 @@ export default function AdminSecuritySection() {
+ {t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')} + + + } description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')} value={settings.loginAttemptCount || 0} onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })} @@ -228,7 +250,12 @@ export default function AdminSecuritySection() {
+ {t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')} + + + } description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')} value={settings.loginResetTimeMinutes || 0} onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })} @@ -295,10 +322,13 @@ export default function AdminSecuritySection() { {t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
- setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })} - /> + + setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })} + /> + +
@@ -308,15 +338,23 @@ export default function AdminSecuritySection() { {t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
- setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })} - /> + + setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })} + /> + +
+ {t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')} + + + } description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')} value={settings.jwt?.keyRetentionDays || 7} onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })} @@ -369,7 +407,12 @@ export default function AdminSecuritySection() {
+ {t('admin.settings.security.audit.level', 'Audit Level')} + + + } description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')} value={settings.audit?.level || 2} onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })} @@ -380,7 +423,12 @@ export default function AdminSecuritySection() {
+ {t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')} + + + } description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')} value={settings.audit?.retentionDays || 90} onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })} @@ -425,7 +473,12 @@ export default function AdminSecuritySection() {