diff --git a/.env.example b/.env.example index b890d46..9fd9965 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,17 @@ LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=replace-with-a-long-random-secret LIVEKIT_PUBLIC_URL=ws://localhost:7880 +# Address on which the application-server gateway listens. Set this server's +# private IP where possible, then allow only the external Nginx server in the firewall. +GATEWAY_BIND_ADDRESS=0.0.0.0 + AUTH_JWT_ISSUER=screen-share-api AUTH_JWT_AUDIENCE=screen-share-web AUTH_JWT_REALM=screen-share AUTH_JWT_SECRET=replace-with-a-different-long-random-secret +# Comma-separated browser origins allowed to call the API. For a local package, +# use http://localhost:8081; for production, use https://your-domain. +CORS_ALLOWED_ORIGINS=http://localhost:8081 BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password BOOTSTRAP_ADMIN_DISPLAY_NAME=管理员 diff --git a/apps/ScreenShareApi/.dockerignore b/apps/ScreenShareApi/.dockerignore index 2534132..dad4817 100644 --- a/apps/ScreenShareApi/.dockerignore +++ b/apps/ScreenShareApi/.dockerignore @@ -1,4 +1,6 @@ .gradle -build +build/* +!build/install/ +!build/install/** .env *.iml diff --git a/apps/ScreenShareApi/Dockerfile b/apps/ScreenShareApi/Dockerfile index a1f0f55..667104a 100644 --- a/apps/ScreenShareApi/Dockerfile +++ b/apps/ScreenShareApi/Dockerfile @@ -1,18 +1,8 @@ -FROM eclipse-temurin:21-jdk AS build -WORKDIR /app - -COPY gradlew gradlew -COPY gradle gradle -COPY build.gradle.kts settings.gradle.kts gradle.properties ./ -RUN chmod +x gradlew -RUN ./gradlew dependencies --no-daemon - -COPY src src -RUN ./gradlew installDist --no-daemon - FROM eclipse-temurin:21-jre WORKDIR /app -COPY --from=build /app/build/install/ScreenShareApi ./ + +# package-release.sh creates this distribution before building the image. +COPY build/install/ScreenShareApi ./ EXPOSE 8080 ENTRYPOINT ["./bin/ScreenShareApi"] diff --git a/apps/ScreenShareApi/gradle/wrapper/gradle-wrapper.properties b/apps/ScreenShareApi/gradle/wrapper/gradle-wrapper.properties index df6a6ad..b2e437a 100644 --- a/apps/ScreenShareApi/gradle/wrapper/gradle-wrapper.properties +++ b/apps/ScreenShareApi/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 +networkTimeout=120000 +retries=3 +retryBackOffMs=1000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/apps/ScreenShareApi/src/main/kotlin/Routing.kt b/apps/ScreenShareApi/src/main/kotlin/Routing.kt index c606441..decacb3 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Routing.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Routing.kt @@ -34,14 +34,17 @@ fun Application.configureRouting() { } } install(CORS) { - val configuredOrigins = this@configureRouting.environment.config - .propertyOrNull("cors.allowedOrigins") - ?.getString() - ?.split(",") + val configuredOrigins = ( + this@configureRouting.environment.config.propertyOrNull("cors.allowedOrigins") + ?.getString() + ?: System.getenv("CORS_ALLOWED_ORIGINS") + ?: "http://localhost:5173" + ) + .split(",") ?.map(String::trim) ?.filter(String::isNotEmpty) .orEmpty() - configuredOrigins.ifEmpty { listOf("http://localhost:5173") }.forEach { origin -> + configuredOrigins.forEach { origin -> allowHost( host = origin.removePrefix("http://").removePrefix("https://"), schemes = listOf(if (origin.startsWith("https://")) "https" else "http"), diff --git a/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt b/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt index eff2d02..c0d3423 100644 --- a/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt +++ b/apps/ScreenShareApi/src/test/kotlin/ServerTest.kt @@ -3,7 +3,6 @@ package top.OrangeRoll import io.ktor.client.request.get import io.ktor.http.HttpStatusCode import io.ktor.server.testing.testApplication -import configureRouting import kotlin.test.* class ServerTest { diff --git a/apps/ScreenShareWeb/.dockerignore b/apps/ScreenShareWeb/.dockerignore deleted file mode 100644 index 2760ced..0000000 --- a/apps/ScreenShareWeb/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.env -npm-debug.log* diff --git a/apps/ScreenShareWeb/Dockerfile b/apps/ScreenShareWeb/Dockerfile deleted file mode 100644 index c78203a..0000000 --- a/apps/ScreenShareWeb/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM node:22-alpine AS build -WORKDIR /app - -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN npm run build - -FROM nginx:1.27-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/dist /usr/share/nginx/html - -EXPOSE 80 diff --git a/apps/ScreenShareWeb/nginx.conf b/apps/ScreenShareWeb/nginx.conf deleted file mode 100644 index 416a992..0000000 --- a/apps/ScreenShareWeb/nginx.conf +++ /dev/null @@ -1,20 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location /api/ { - proxy_pass http://api:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/docker-compose.yml b/docker-compose.yml index 30de538..a337f46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,7 @@ services: AUTH_JWT_AUDIENCE: ${AUTH_JWT_AUDIENCE} AUTH_JWT_REALM: ${AUTH_JWT_REALM} AUTH_JWT_SECRET: ${AUTH_JWT_SECRET} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD} BOOTSTRAP_ADMIN_DISPLAY_NAME: ${BOOTSTRAP_ADMIN_DISPLAY_NAME} @@ -55,14 +56,17 @@ services: ports: - "127.0.0.1:8080:8080" - web: - image: screenshare-web:${SCREENSHARE_VERSION:-latest} - build: - context: ./apps/ScreenShareWeb + gateway: + image: nginx:1.27-alpine depends_on: - api + - livekit + volumes: + - ./web:/usr/share/nginx/html:ro + - ./infra/nginx/gateway.conf:/etc/nginx/conf.d/default.conf:ro ports: - - "127.0.0.1:5173:80" + # The external Nginx server connects to this port over the private network. + - "${GATEWAY_BIND_ADDRESS:-0.0.0.0}:8081:80" volumes: postgres-data: diff --git a/docs/SERVER_DEPLOYMENT.md b/docs/SERVER_DEPLOYMENT.md index e167faa..e212569 100644 --- a/docs/SERVER_DEPLOYMENT.md +++ b/docs/SERVER_DEPLOYMENT.md @@ -4,12 +4,12 @@ ## 1. 在构建机生成部署包 -构建机需要 Docker,并能拉取镜像。 +构建机需要 Docker、JDK 21 和 Node.js/npm。脚本先在宿主机执行 Gradle 和 npm 构建;前端 `dist` 放入部署包的 `web` 目录,由应用服务器的 Gateway Nginx 容器托管。外部 Nginx 只需反代 Gateway 的一个地址。 ~~~bash cd /path/to/ScreenShare chmod +x scripts/package-release.sh -sudo ./scripts/package-release.sh 0.1.0 +./scripts/package-release.sh 0.1.0 ~~~ 生成文件: @@ -18,12 +18,43 @@ sudo ./scripts/package-release.sh 0.1.0 release/screen-share-0.1.0.tar.gz ~~~ -如使用 sudo 运行,按需将 release 目录归还给当前用户: +若当前账户没有 Docker socket 权限,脚本会仅对 Docker 命令自动调用 `sudo`。不要使用 `sudo ./scripts/package-release.sh`,这样 Gradle/npm 才能使用当前用户已经下载的依赖缓存。 + +脚本默认使用本项目开发机 IntelliJ 下载的 JDK 21:`/home/orangeroll/.jdks/ms-21.0.12`,无需手动设置 `JAVA_HOME`。换电脑或升级 JDK 时,直接修改 `scripts/package-release.sh` 顶部的 `project_java_home` 路径。 + +### Docker 代理 + +Docker daemon 不会自动使用桌面或终端的系统代理。若构建机缺少 API、PostgreSQL、LiveKit 或 Nginx 镜像且访问 Docker Hub 较慢,先为 Docker daemon 配置 HTTP/HTTPS 代理。下面以本机 HTTP 代理端口 7890 为例;如果使用 Clash 等工具,应填写 HTTP 代理端口,不是 SOCKS 端口。已有本地镜像会被脚本直接复用。 ~~~bash -sudo chown -R $USER:$USER release +sudo systemctl edit docker ~~~ +写入: + +~~~ini +[Service] +Environment="HTTP_PROXY=http://127.0.0.1:7890" +Environment="HTTPS_PROXY=http://127.0.0.1:7890" +Environment="NO_PROXY=localhost,127.0.0.1,::1,postgres,api,livekit" +~~~ + +然后执行: + +~~~bash +sudo systemctl daemon-reload +sudo systemctl restart docker +sudo docker info | grep -i proxy +~~~ + +重启 Docker 会短暂影响运行中的容器。Gradle/npm 现在在宿主机运行,直接使用当前用户的网络和本地缓存;Docker daemon 代理只负责拉取基础镜像。 + +~~~bash +./scripts/package-release.sh 0.1.0 +~~~ + +不要把 SOCKS 代理端口直接填入 Docker daemon 配置。 + ## 2. 传输并导入服务器 服务器需要 Docker Compose,但不需要访问镜像仓库。 @@ -51,16 +82,20 @@ POSTGRES_PASSWORD=替换为随机数据库密码 LIVEKIT_API_KEY=生产环境Key LIVEKIT_API_SECRET=替换为随机LiveKit密钥 LIVEKIT_PUBLIC_URL=wss://share.example.com +GATEWAY_BIND_ADDRESS=应用服务器内网IP AUTH_JWT_SECRET=替换为随机JWT密钥 +CORS_ALLOWED_ORIGINS=https://share.example.com BOOTSTRAP_ADMIN_PASSWORD=替换为强管理员密码 SCREENSHARE_VERSION=0.1.0 ~~~ -DOMAIN 仅供文档和 Nginx 示例使用;本项目由你已有的 Nginx 终止 HTTPS。 +`GATEWAY_BIND_ADDRESS` 推荐填应用服务器的内网 IP。也可暂时保留默认 `0.0.0.0`,但必须在应用服务器防火墙中只允许外部 Nginx 服务器访问 `8081/TCP`。 -## 4. 配置 Nginx +## 4. 配置外部 Nginx -复制 infra/nginx/screenshare.conf.example 的 server 配置到现有 Nginx 站点,将 share.example.com、证书路径改为真实值。map 指令只能在 http 块中声明一次。 +部署包内的 `gateway` 服务负责静态前端、`/api/` 与 LiveKit 信令;外部 Nginx 只需要一个上游地址。复制 `infra/nginx/screenshare.conf.example` 的 server 配置到外部 Nginx 站点,将 `share.example.com`、证书路径和 `10.0.0.20:8081` 改为真实值。`map` 指令只能在 `http` 块中声明一次。 + +外部 Nginx 不需要复制 `web` 目录,也不需要分别配置 `/api/`、`/rtc/` 或 `/twirp/`。 验证并重载: @@ -81,14 +116,17 @@ sudo docker compose logs -f api ## 6. 防火墙与安全组 -开放: +应用服务器开放: ~~~text -TCP 80, 443, 7881 +TCP 8081(仅允许外部 Nginx 的内网 IP) +TCP 7881(浏览器直接访问 LiveKit 的 TCP 兜底) UDP 50000-50100 ~~~ -不要开放 PostgreSQL 5432、API 8080、LiveKit HTTP/WebSocket 7880;它们只绑定到服务器 127.0.0.1。 +外部 Nginx 服务器向公网开放 TCP `80, 443`。不要公开 PostgreSQL `5432`、API `8080` 或 LiveKit HTTP/WebSocket `7880`;它们只供 Docker 内部 Gateway 使用。 + +浏览器的 WebRTC 媒体不会经过任一 Nginx,仍须直连应用服务器的 LiveKit `7881/TCP` 和 `50000-50100/UDP`。生产环境还需要在 LiveKit 中正确声明公网 IP/NAT。 ## 7. 更新版本 diff --git a/infra/nginx/screenshare.conf.example b/infra/nginx/screenshare.conf.example index 150f96f..e1fa540 100644 --- a/infra/nginx/screenshare.conf.example +++ b/infra/nginx/screenshare.conf.example @@ -1,4 +1,5 @@ -# Put the map block in nginx.conf's http {} section, only once. +# External Nginx configuration. Put the map block in nginx.conf's http {} +# section only once. Replace 10.0.0.20 with the application server's private IP. map $http_upgrade $connection_upgrade { default upgrade; '' close; @@ -19,30 +20,14 @@ server { ssl_certificate_key /etc/letsencrypt/live/share.example.com/privkey.pem; location / { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - } - - location /api/ { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - } - - location /rtc/ { - proxy_pass http://127.0.0.1:7880; + # This one upstream handles the frontend, API, and LiveKit signalling. + proxy_pass http://10.0.0.20:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; proxy_read_timeout 3600s; } - - location /twirp/ { - proxy_pass http://127.0.0.1:7880; - proxy_set_header Host $host; - } } diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 8b06ecb..8d235d8 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -7,29 +7,136 @@ if [[ -z "$version" ]]; then exit 1 fi +# 修改此处为本机 JDK 21 的实际安装目录。 +project_java_home="/home/orangeroll/.jdks/ms-21.0.12" +if [[ ! -x "$project_java_home/bin/java" ]]; then + echo "Java 21 was not found at: $project_java_home" + echo "Update project_java_home in scripts/package-release.sh." + exit 1 +fi + +export JAVA_HOME="$project_java_home" +export PATH="$JAVA_HOME/bin:$PATH" +echo "Using Java: $JAVA_HOME" + +if ! command -v npm >/dev/null 2>&1; then + echo "npm was not found. Install Node.js/npm before packaging." + exit 1 +fi + script_dir="$(cd "$(dirname "$0")" && pwd)" project_dir="$(cd "$script_dir/.." && pwd)" release_dir="$project_dir/release" bundle_dir="$release_dir/screen-share-$version" archive="$release_dir/screen-share-$version.tar.gz" -if [[ -e "$bundle_dir" || -e "$archive" ]]; then - echo "Release already exists: $bundle_dir or $archive" +if ! mkdir -p "$release_dir" || [[ ! -w "$release_dir" ]]; then + echo "Release directory is not writable: $release_dir" + echo "Fix it once with: sudo chown -R \$USER:\$USER \"$release_dir\"" exit 1 fi -mkdir -p "$bundle_dir/infra/livekit" "$bundle_dir/infra/nginx" "$bundle_dir/docs" +if docker info >/dev/null 2>&1; then + docker_command=(docker) +else + docker_command=(sudo docker) +fi -docker build -t "screenshare-api:$version" "$project_dir/apps/ScreenShareApi" -docker build -t "screenshare-web:$version" "$project_dir/apps/ScreenShareWeb" -docker pull postgres:16-alpine -docker pull livekit/livekit-server:v1.9.10 +run_docker() { + "${docker_command[@]}" "$@" +} -docker save + -o "$bundle_dir/images.tar" + "screenshare-api:$version" + "screenshare-web:$version" + postgres:16-alpine + livekit/livekit-server:v1.9.10 +ensure_docker_image() { + local image="$1" + if run_docker image inspect "$image" >/dev/null 2>&1; then + echo "Using local Docker image: $image" + else + retry run_docker pull "$image" + fi +} +if [[ -e "$bundle_dir" || -e "$archive" ]]; then + if [[ -d "$bundle_dir" && ! -e "$archive" ]] && [[ -z "$(find "$bundle_dir" -type f -print -quit)" ]]; then + find "$bundle_dir" -depth -type d -empty -delete + else + echo "Release already exists: $bundle_dir or $archive" + exit 1 + fi +fi + +retry() { + local attempt + for attempt in 1 2 3; do + if "$@"; then + return 0 + fi + if [[ "$attempt" -lt 3 ]]; then + echo "Command failed; retrying in 5 seconds ($attempt/3)..." + sleep 5 + fi + done + return 1 +} + +docker_build() { + local build_args=() + local network_args=() + local proxy_enabled=false + local variable + local value + local build_network="${DOCKER_BUILD_NETWORK-}" + for variable in HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy; do + value="${!variable-}" + if [[ -n "$value" ]]; then + build_args+=(--build-arg "$variable=$value") + proxy_enabled=true + fi + done + if [[ "$proxy_enabled" == true ]]; then + echo "Using proxy environment variables for Docker build containers." + fi + if [[ -n "$build_network" ]]; then + network_args=(--network "$build_network") + echo "Using Docker build network: $build_network" + fi + run_docker build "${build_args[@]}" "${network_args[@]}" "$@" +} + +build_api_distribution() { + ( + cd "$project_dir/apps/ScreenShareApi" + ./gradlew installDist --no-daemon + ) +} + +build_web_assets() { + ( + cd "$project_dir/apps/ScreenShareWeb" + npm run build + ) +} + +build_api_distribution +build_web_assets +retry docker_build -t "screenshare-api:$version" "$project_dir/apps/ScreenShareApi" +ensure_docker_image postgres:16-alpine +ensure_docker_image livekit/livekit-server:v1.9.10 +ensure_docker_image nginx:1.27-alpine + +mkdir -p "$bundle_dir/infra/livekit" "$bundle_dir/infra/nginx" "$bundle_dir/docs" "$bundle_dir/web" + +run_docker save \ + "screenshare-api:$version" \ + postgres:16-alpine \ + livekit/livekit-server:v1.9.10 \ + nginx:1.27-alpine \ + > "$bundle_dir/images.tar" + +cp -a "$project_dir/apps/ScreenShareWeb/dist/." "$bundle_dir/web/" cp "$project_dir/docker-compose.yml" "$bundle_dir/" cp "$project_dir/.env.example" "$bundle_dir/" cp "$project_dir/infra/livekit/livekit.yaml" "$bundle_dir/infra/livekit/" +cp "$project_dir/infra/nginx/gateway.conf" "$bundle_dir/infra/nginx/" cp "$project_dir/infra/nginx/screenshare.conf.example" "$bundle_dir/infra/nginx/" cp "$project_dir/docs/SERVER_DEPLOYMENT.md" "$bundle_dir/docs/"