mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
34f244e7a4
commit
d0b7a7c5d4
11 changed files with 175 additions and 16 deletions
|
|
@ -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=
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue