feat: make reverse proxy operator-managed
Release / release (push) Failing after 10s

This commit is contained in:
Qiufeng
2026-08-17 16:24:32 +08:00
parent aed561c702
commit 7394c9e7e4
20 changed files with 358 additions and 124 deletions
@@ -3,9 +3,11 @@ package com.kaidi.finance.setup;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.RequestMatcher;
@Configuration
@ConditionalOnProperty(name = "finance.setup.enabled", havingValue = "true")
@@ -19,8 +21,20 @@ public class SetupSecurityConfig {
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error").permitAll()
.requestMatchers("/api/v1/setup/**", "/actuator/health/**", "/error",
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
.anyRequest().denyAll());
return http.build();
}
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
&& !path.equals("/error");
}
}
@@ -14,6 +14,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
@Configuration
@EnableMethodSecurity
@@ -37,7 +38,9 @@ public class SecurityConfig {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/v1/auth/csrf", "/api/v1/auth/login", "/actuator/health/**",
"/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error").permitAll()
"/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/error",
"/", "/index.html", "/favicon.ico", "/assets/**").permitAll()
.requestMatchers((RequestMatcher) request -> isSpaRoute(request)).permitAll()
.requestMatchers("/api/v1/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(exceptions -> exceptions
@@ -48,6 +51,18 @@ public class SecurityConfig {
return http.build();
}
private boolean isSpaRoute(jakarta.servlet.http.HttpServletRequest request) {
if (!HttpMethod.GET.matches(request.getMethod()) && !HttpMethod.HEAD.matches(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
return !path.contains(".") && !path.equals("/api") && !path.startsWith("/api/")
&& !path.equals("/actuator") && !path.startsWith("/actuator/")
&& !path.equals("/api-docs") && !path.startsWith("/api-docs/")
&& !path.equals("/swagger-ui.html") && !path.startsWith("/swagger-ui/")
&& !path.equals("/error");
}
@Bean
PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@@ -0,0 +1,51 @@
package com.kaidi.finance.shared.web;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Forwards extensionless browser routes to the bundled Vue entry point. */
@Component
public class SpaForwardFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isBrowserRoute(request)) {
request.getRequestDispatcher("/index.html").forward(request, response);
return;
}
filterChain.doFilter(request, response);
}
private boolean isBrowserRoute(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod()) && !"HEAD".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
if (path.isEmpty()) {
path = "/";
}
if (isReserved(path) || path.contains(".")) {
return false;
}
return DispatcherType.REQUEST.equals(request.getDispatcherType());
}
private boolean isReserved(String path) {
return path.equals("/api") || path.startsWith("/api/")
|| path.equals("/actuator") || path.startsWith("/actuator/")
|| path.equals("/api-docs") || path.startsWith("/api-docs/")
|| path.equals("/swagger-ui.html") || path.startsWith("/swagger-ui/")
|| path.equals("/error");
}
}
@@ -1,5 +1,7 @@
package com.kaidi.setup;
import com.kaidi.finance.setup.SetupProperties;
import com.kaidi.finance.shared.web.SpaForwardFilter;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -9,7 +11,7 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.kaidi.finance.setup.SetupProperties;
import org.springframework.context.annotation.Import;
/**
* Minimal first-run context. It deliberately does not create a business DataSource or run
@@ -28,6 +30,7 @@ import com.kaidi.finance.setup.SetupProperties;
}
)
@EnableConfigurationProperties(SetupProperties.class)
@Import(SpaForwardFilter.class)
public class SetupApplication {
public static void main(String[] args) {
@@ -1,5 +1,6 @@
server:
port: ${SERVER_PORT:18080}
address: ${SERVER_ADDRESS:127.0.0.1}
shutdown: graceful
servlet:
session:
@@ -12,6 +13,9 @@ server:
spring:
application:
name: kaidi-finance
web:
resources:
static-locations: ${FINANCE_STATIC_LOCATIONS:file:./public/}
profiles:
default: local
datasource:
@@ -31,6 +31,7 @@ class SetupContextSmokeTest {
registry.add("finance.setup.env-file", () -> STATE_ROOT.resolve("application.env").toString());
registry.add("finance.setup.marker-file", () -> STATE_ROOT.resolve("locked").toString());
registry.add("finance.setup.restart-after-complete", () -> false);
registry.add("spring.web.resources.static-locations", () -> "classpath:/spa-fixture/");
}
@Autowired
@@ -49,6 +50,14 @@ class SetupContextSmokeTest {
assertThat(status.getBody().path("data").path("supportedDatabaseTypes").toString())
.isEqualTo("[\"MYSQL\"]");
ResponseEntity<String> setupPage = rest.getForEntity(url("/setup"), String.class);
assertThat(setupPage.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(setupPage.getBody()).contains("kaidi-spa-fixture");
ResponseEntity<String> asset = rest.getForEntity(url("/assets/app.js"), String.class);
assertThat(asset.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(asset.getBody()).contains("kaidi-spa-fixture");
ResponseEntity<JsonNode> business = rest.getForEntity(url("/api/v1/auth/session"), JsonNode.class);
assertThat(business.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@@ -0,0 +1,43 @@
package com.kaidi.finance.shared.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class SpaForwardFilterTest {
private final SpaForwardFilter filter = new SpaForwardFilter();
@Test
void forwardsExtensionlessBrowserRouteToVueEntryPoint() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/finance/workbench");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(response.getForwardedUrl()).isEqualTo("/index.html");
assertThat(continued).isFalse();
}
@Test
void leavesApiAndStaticAssetRequestsToSpringMvc() throws Exception {
assertContinues("/api/v1/unknown-resource");
assertContinues("/actuator/health");
assertContinues("/assets/index-a1b2c3.js");
}
private void assertContinues(String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET", uri);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean continued = new AtomicBoolean();
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> continued.set(true));
assertThat(continued).isTrue();
assertThat(response.getForwardedUrl()).isNull();
}
}
@@ -0,0 +1 @@
window.__KAIDI_SPA_FIXTURE__ = 'kaidi-spa-fixture';
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>kaidi-spa-fixture</title></head>
<body><div id="app">kaidi-spa-fixture</div></body>
</html>