fix(deploy): backport sub-path runtime fixes to PR 576

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-06 18:59:37 +08:00
parent 34f244e7a4
commit d0b7a7c5d4
11 changed files with 175 additions and 16 deletions

View file

@ -128,7 +128,9 @@ SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
# To enable, set BOTH:
# - SKILLHUB_AUTH_DIRECT_ENABLED=true (server: enables the /api/v1/auth/direct endpoint)
# - SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true (web: surfaces the username/password form)
# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to the provider id (e.g. "local").
# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to a direct provider id returned by
# /api/v1/auth/methods (e.g. "local"). Do not use the built-in auth method id
# "local-password" here; that method points at /api/v1/auth/local/login.
SKILLHUB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=

View file

@ -246,6 +246,9 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
# Aliyun mirror (recommended for users in China)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
# Sub-path deployment behind a reverse proxy
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com/skillhub --base-path /skillhub/
```
**Deployment parameters:**
@ -253,6 +256,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
| Parameter | Description | Example |
|-----------|-------------|---------|
| `--public-url <url>` | Public access URL (recommended) | `--public-url https://skill.example.com` |
| `--base-path <path>` | Serve the Web UI under a reverse-proxy sub-path. Also sets same-origin API routing for that path. | `--base-path /skillhub/` |
| `--version <tag>` | Specific image tag | `--version v0.2.0` |
| `--aliyun` | Use Aliyun mirror (China) | `--aliyun` |
| `--home <dir>` | Runtime directory | `--home /opt/skillhub` |
@ -260,6 +264,12 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
> **Important**: Configure `--public-url` for production deployments to ensure CLI install commands and Agent setup instructions display the correct URLs.
For sub-path deployments, keep the public URL and base path aligned. For example,
`--public-url https://skill.example.com/skillhub --base-path /skillhub/` writes
`SKILLHUB_PUBLIC_BASE_URL=https://skill.example.com/skillhub`,
`SKILLHUB_WEB_BASE_PATH=/skillhub/`, and `SKILLHUB_WEB_API_BASE_URL=/skillhub`
into the runtime environment.
**Manual deployment:**
1. Copy the runtime environment template.
@ -308,6 +318,9 @@ enables the bootstrap admin by default, so zero-config quickstart via
Recommended production baseline:
- set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint
- if the service is published under a sub-path such as `/skillhub/`, set
`SKILLHUB_WEB_BASE_PATH=/skillhub/` and `SKILLHUB_WEB_API_BASE_URL=/skillhub`
as well, or use `runtime.sh --base-path /skillhub/`
- keep PostgreSQL / Redis bound to `127.0.0.1`
- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*`
- change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`)

View file

@ -235,6 +235,9 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
# 阿里云镜像(国内推荐)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
# 通过反向代理部署到子路径
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com/skillhub --base-path /skillhub/
```
### 配置参数说明
@ -242,6 +245,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
| 参数 | 说明 | 示例 |
|------|------|------|
| `--public-url <url>` | 公网访问地址(推荐配置) | `--public-url https://skill.example.com` |
| `--base-path <path>` | 将 Web UI 发布到反向代理子路径,并同步配置同源 API 路由 | `--base-path /skillhub/` |
| `--version <tag>` | 指定镜像版本 | `--version v0.2.0` |
| `--aliyun` | 使用阿里云镜像(国内推荐) | `--aliyun` |
| `--home <dir>` | 指定运行时目录 | `--home /opt/skillhub` |
@ -249,6 +253,11 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
> **重要**:生产环境请务必配置 `--public-url`,确保 CLI 安装命令和 Agent 设置指引显示正确的地址。
如果通过 `/skillhub/` 这类子路径对外发布,需要让公网地址和前端基础路径保持一致。
例如 `--public-url https://skill.example.com/skillhub --base-path /skillhub/`
会写入 `SKILLHUB_PUBLIC_BASE_URL=https://skill.example.com/skillhub`
`SKILLHUB_WEB_BASE_PATH=/skillhub/``SKILLHUB_WEB_API_BASE_URL=/skillhub`
### 使用 Kubernetes
```bash

View file

@ -13,6 +13,7 @@ SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime"
SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}"
SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}"
SKILLHUB_PUBLIC_BASE_URL_VALUE="${SKILLHUB_PUBLIC_BASE_URL:-}"
SKILLHUB_WEB_BASE_PATH_VALUE="${SKILLHUB_WEB_BASE_PATH:-}"
SKILLHUB_ALIYUN_REGISTRY="${SKILLHUB_ALIYUN_REGISTRY:-crpi-ptu2rqimrigtq0qx.cn-hangzhou.personal.cr.aliyuncs.com}"
SKILLHUB_ALIYUN_NAMESPACE="${SKILLHUB_ALIYUN_NAMESPACE:-skill_hub}"
SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_MIRROR_REGISTRY:-}"
@ -89,6 +90,11 @@ while [ "$#" -gt 0 ]; do
SKILLHUB_PUBLIC_BASE_URL_VALUE="$2"
shift 2
;;
--base-path)
[ "$#" -ge 2 ] || { echo "Missing value for --base-path" >&2; exit 1; }
SKILLHUB_WEB_BASE_PATH_VALUE="$2"
shift 2
;;
--help|-h)
cat <<EOF
Usage: sh runtime.sh [up|down|clean|ps|logs|pull] [options]
@ -100,6 +106,7 @@ Options:
--home <dir> Store runtime files in a specific directory
--ref <git-ref> Download runtime files from a specific Git ref
--public-url <url> Public access URL (e.g. https://skill.example.com)
--base-path <path> Serve Web UI under a sub-path, e.g. /skillhub/
--server-image <img> Override backend image repository
--web-image <img> Override frontend image repository
--scanner-image <img> Override scanner image repository
@ -183,6 +190,46 @@ set_env_value() {
secure_env_file
}
normalize_base_path() {
value="$1"
[ -n "$value" ] || { echo "--base-path must not be empty" >&2; exit 1; }
case "$value" in
/)
printf '/'
return 0
;;
/*/) ;;
/*) value="$value/" ;;
*) value="/$value/" ;;
esac
case "$value" in
*//*|*[!A-Za-z0-9._~/-]*)
echo "--base-path contains unsupported characters: $value" >&2
exit 1
;;
esac
case "$value" in
*/./*|*/../*)
echo "--base-path must not contain '.' or '..' path segments: $value" >&2
exit 1
;;
esac
first_segment=${value#/}
first_segment=${first_segment%%/*}
case "$first_segment" in
api|oauth2|login|assets|registry|nginx-health|.well-known|runtime-config.js)
echo "--base-path must not start with a reserved SkillHub path segment: $first_segment" >&2
exit 1
;;
esac
printf '%s' "$value"
}
secure_env_file() {
if [ -f "$ENV_FILE" ]; then
chmod 600 "$ENV_FILE"
@ -359,6 +406,16 @@ prepare_runtime_files() {
set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE"
fi
if [ -n "$SKILLHUB_WEB_BASE_PATH_VALUE" ]; then
normalized_base_path="$(normalize_base_path "$SKILLHUB_WEB_BASE_PATH_VALUE")"
set_env_value "SKILLHUB_WEB_BASE_PATH" "$normalized_base_path"
if [ "$normalized_base_path" = "/" ]; then
set_env_value "SKILLHUB_WEB_API_BASE_URL" ""
else
set_env_value "SKILLHUB_WEB_API_BASE_URL" "${normalized_base_path%/}"
fi
fi
if [ "$DISABLE_SCANNER" = "true" ]; then
set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false"
fi

View file

@ -120,4 +120,18 @@ grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.releas
grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \
|| fail "runtime --no-scanner should start server/web without waiting on scanner dependencies"
home_sub_path="$tmp/sub-path"
stdout_sub_path="$tmp/sub-path.out"
mkdir -p "$home_sub_path"
run_runtime "$home_sub_path" "$bin_dir" "$stdout_sub_path" \
--public-url http://localhost/skillhub \
--base-path skillhub
grep -Fq "SKILLHUB_PUBLIC_BASE_URL=http://localhost/skillhub" "$home_sub_path/.env.release" \
|| fail "runtime should persist the public URL for sub-path deployments"
grep -Fq "SKILLHUB_WEB_BASE_PATH=/skillhub/" "$home_sub_path/.env.release" \
|| fail "runtime should normalize and persist SKILLHUB_WEB_BASE_PATH"
grep -Fq "SKILLHUB_WEB_API_BASE_URL=/skillhub" "$home_sub_path/.env.release" \
|| fail "runtime should align SKILLHUB_WEB_API_BASE_URL with the base path"
echo "runtime-secret-test passed"

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@ -11,14 +12,8 @@ import java.util.Map;
*/
@RestController
public class WellKnownController {
@GetMapping("/.well-known/clawhub.json")
public Map<String, String> clawhubConfig(HttpServletRequest request) {
// Honor the deployment sub-path so CLI clients discover /<prefix>/api/v1 instead of
// the domain-root /api/v1. With forward-headers-strategy=framework, an upstream
// X-Forwarded-Prefix is reflected into the request context path.
String contextPath = request.getContextPath();
String prefix = (contextPath == null) ? "" : contextPath;
return Map.of("apiBase", prefix + "/api/v1");
return Map.of("apiBase", OAuthLoginRedirectSupport.apiBase(request));
}
}

View file

@ -59,7 +59,7 @@ public class AuthContextFilter extends OncePerRequestFilter {
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(request.getRequestURI())) {
if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(requestPath(request))) {
filterChain.doFilter(request, response);
return;
}
@ -88,6 +88,14 @@ public class AuthContextFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private String requestPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null && !servletPath.isBlank()) {
return servletPath;
}
return request.getRequestURI();
}
private boolean isInactiveUser(String userId) {
if (!enforceActiveUserCheck) {
return false;

View file

@ -120,6 +120,34 @@ class AuthContextFilterTest {
verify(filterChain).doFilter(request, response);
}
@Test
void forwardedPrefixApiRequest_shouldUseServletPathForContextProjection() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("user-3", "Cara", "cara@example.com", null, "local", Set.of("USER"));
UserAccount user = new UserAccount("user-3", "Cara", "cara@example.com", null);
user.setStatus(UserStatus.ACTIVE);
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("/skillhub");
request.setRequestURI("/skillhub/api/web/me/namespaces");
request.setServletPath("/api/web/me/namespaces");
request.getSession(true).setAttribute("platformPrincipal", principal);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, List.of())
);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
when(userAccountRepository.findById("user-3")).thenReturn(java.util.Optional.of(user));
when(namespaceMemberRepository.findByUserId("user-3")).thenReturn(List.of());
filter.doFilter(request, response, filterChain);
assertEquals("user-3", request.getAttribute("userId"));
assertTrue(((java.util.Map<Long, NamespaceRole>) request.getAttribute("userNsRoles")).isEmpty());
verify(filterChain).doFilter(request, response);
}
@Test
void anonymousRequest_shouldPassThroughWithoutLoadingUserContext() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.CustomOidcUserService;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
import com.iflytek.skillhub.auth.mock.MockAuthFilter;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
@ -152,12 +153,8 @@ public class SecurityConfig {
)
.logout(logout -> logout
.logoutUrl("/api/v1/auth/logout")
// Redirect to the deployment root honoring any sub-path prefix (X-Forwarded-Prefix
// is reflected into the context path), so logout does not escape a sub-path deployment.
.logoutSuccessHandler((request, response, authentication) -> {
String contextPath = request.getContextPath();
response.sendRedirect(((contextPath == null) ? "" : contextPath) + "/");
})
.logoutSuccessHandler((request, response, authentication) ->
response.sendRedirect(OAuthLoginRedirectSupport.webRoot(request)))
.invalidateHttpSession(true)
.deleteCookies("SESSION")
)

View file

@ -1,7 +1,10 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpServletRequest;
/**
* Utility methods and constants for safely handling post-login redirect targets in OAuth flows.
* Utility methods and constants for safely handling OAuth redirect targets and
* deployment-path aware browser-visible URLs.
*/
public final class OAuthLoginRedirectSupport {
@ -11,6 +14,30 @@ public final class OAuthLoginRedirectSupport {
private OAuthLoginRedirectSupport() {
}
public static String apiBase(HttpServletRequest request) {
return deploymentPrefix(request) + "/api/v1";
}
public static String webRoot(HttpServletRequest request) {
return deploymentPrefix(request) + "/";
}
static String deploymentPrefix(HttpServletRequest request) {
if (request == null) {
return "";
}
String rawPrefix = request.getContextPath();
if (rawPrefix == null || rawPrefix.isBlank() || "/".equals(rawPrefix)) {
return "";
}
String prefix = rawPrefix.endsWith("/") ? rawPrefix.substring(0, rawPrefix.length() - 1) : rawPrefix;
if (!prefix.startsWith("/") || prefix.contains("//") || prefix.contains("\\")
|| prefix.contains("?") || prefix.contains("#")) {
return "";
}
return prefix;
}
public static String sanitizeReturnTo(String candidate) {
if (candidate == null || candidate.isBlank()) {
return null;

View file

@ -108,6 +108,15 @@ class OAuth2LoginHandlersTest {
assertThat(response.getRedirectedUrl()).isEqualTo("/skillhub/dashboard/publish");
}
@Test
void deploymentPathSupport_returnsPrefixedApiAndWebRoot() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("/skillhub");
assertThat(OAuthLoginRedirectSupport.apiBase(request)).isEqualTo("/skillhub/api/v1");
assertThat(OAuthLoginRedirectSupport.webRoot(request)).isEqualTo("/skillhub/");
}
/**
* Regression test: when an unauthenticated client hits a protected API endpoint, Spring Security
* caches that request. With {@code SavedRequestAwareAuthenticationSuccessHandler} the post-login