diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e014fab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.codegraph +.superpowers +.worktrees +.tmp +.playwright-mcp +.env +**/node_modules +**/dist +**/*_test.go +**/testdata diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f2cf6c --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Alisten 相关 +ALISTEN_HOST=127.0.0.1 # 绑定的 ip +ALISTEN_PORT=9876 # 监听的端口 +ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL +ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error +ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小 +ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台 + +# 公网访问相关 +ACME_EMAIL="" # Let's Encrypt 邮箱 +ALISTEN_DOMAIN="" # Alisten 域名 +STORAGE_DOMAIN="" # 对象存储服务域名 +ALISTEN_PUBLIC_URL="https://${ALISTEN_DOMAIN}" # 公网访问 URL + +# 数据库相关 +DATABASE_HOST=postgres # 数据库主机 +DATABASE_PORT=5432 # 数据库端口 +DATABASE_USERNAME=alisten # 数据库用户名 +DATABASE_PASSWORD="" # 数据库密码 +DATABASE_NAME=alisten # 数据库名称 +DATABASE_SSLMODE=prefer # SSL 模式 + +# redis 相关 +REDIS_HOST=redis # redis 主机 +REDIS_PORT=6379 # redis 端口 +REDIS_PASSWORD="" # redis 密码 +REDIS_DB=0 # redis 数据库 + +# 对象存储相关 +STORAGE_ENDPOINT_URL=http://rustfs:9000 # AWS S3 兼容的对象存储服务端点 +STORAGE_REGION=us-east-1 # 区域 +STORAGE_BUCKET_NAME=alisten # 存储桶名称 +STORAGE_ACCESS_KEY_ID="" # 访问密钥 ID +STORAGE_SECRET_ACCESS_KEY="" # 秘密访问密钥 +STORAGE_PUBLIC_URL="" # AWS S3 公开访问地址 + +# 可选 +OTEL_EXPORTER_OTLP_ENDPOINT="" # OTLP/HTTP 地址 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36cc560 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build +ARG TARGETOS TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +COPY cmd ./cmd +COPY db ./db +COPY internal ./internal +COPY protocol ./protocol +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \ + -ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \ + -o /out/alisten ./cmd/alisten + +FROM gcr.io/distroless/static-debian13:nonroot AS alisten +COPY LICENSE /usr/share/doc/alisten/ +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /out/alisten /usr/local/bin/alisten +USER 65532:65532 +EXPOSE 8080 9090 +HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \ + CMD ["/usr/local/bin/alisten", "healthcheck"] +ENTRYPOINT ["/usr/local/bin/alisten"] +CMD ["serve"] diff --git a/compose.local.yaml b/compose.local.yaml new file mode 100644 index 0000000..67dcfee --- /dev/null +++ b/compose.local.yaml @@ -0,0 +1,20 @@ +services: + migrate: + pull_policy: never + + serve: + pull_policy: never + build: + context: . + + worker: + pull_policy: never + build: + context: . + dockerfile: worker.Dockerfile + + web: + pull_policy: never + build: + context: . + dockerfile: web/Dockerfile diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..70790b8 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,107 @@ +services: + traefik: + image: traefik:v3.7.16 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=proxy + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.http.tls.certresolver=letsencrypt + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --ping=true + - --log.format=json + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + networks: [proxy] + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + start_period: 30s + start_interval: 1s + interval: 10s + timeout: 3s + retries: 3 + security_opt: + - no-new-privileges:true + + migrate: + networks: + - db + - backend + + serve: + environment: + ALISTEN_PUBLIC_URL: https://${ALISTEN_DOMAIN} + STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL} + networks: + - backend + - db + - proxy + labels: + - traefik.enable=true + - traefik.http.services.serve.loadbalancer.server.port=8080 + - traefik.http.routers.serve.rule=Host(`${ALISTEN_DOMAIN}`) && (PathPrefix(`/api/`) || Path(`/ws`) || PathPrefix(`/media/`)) + - traefik.http.routers.serve.entrypoints=websecure + - traefik.http.routers.serve.service=serve + - traefik.http.services.serve.loadbalancer.healthcheck.path=/readyz + - traefik.http.services.serve.loadbalancer.healthcheck.port=9090 + - traefik.http.services.serve.loadbalancer.healthcheck.interval=1s + - traefik.http.services.serve.loadbalancer.healthcheck.timeout=1s + + worker: + networks: + - backend + - db + + web: + ports: !reset [] + environment: !reset {} + networks: + - proxy + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`${ALISTEN_DOMAIN}`) + - traefik.http.routers.web.entrypoints=websecure + - traefik.http.services.web.loadbalancer.server.port=8080 + + postgres: + networks: + - db + + redis: + networks: + - db + + rustfs: + networks: + - proxy + - backend + labels: + - traefik.enable=true + - traefik.http.routers.storage.rule=Host(`${STORAGE_DOMAIN}`) && PathPrefix(`/${STORAGE_BUCKET_NAME}/link/`) && (Method(`GET`) || Method(`HEAD`)) + - traefik.http.routers.storage.entrypoints=websecure + - traefik.http.routers.storage.middlewares=storage-headers + - traefik.http.middlewares.storage-headers.headers.contentsecuritypolicy=sandbox; default-src 'none' + - traefik.http.middlewares.storage-headers.headers.contenttypenosniff=true + - traefik.http.services.storage.loadbalancer.server.port=9000 + +networks: + proxy: + name: proxy + backend: + name: alisten-backend + db: + name: alisten-db + +volumes: + letsencrypt: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..74bbdb8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,137 @@ +x-alisten-env: &alisten-env + ALISTEN_LOG_LEVEL: ${ALISTEN_LOG_LEVEL} + DATABASE_HOST: ${DATABASE_HOST} + DATABASE_PORT: ${DATABASE_PORT} + DATABASE_NAME: ${DATABASE_NAME} + DATABASE_USERNAME: ${DATABASE_USERNAME} + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + DATABASE_SSLMODE: ${DATABASE_SSLMODE} + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + REDIS_DB: ${REDIS_DB} + REDIS_PASSWORD: ${REDIS_PASSWORD} + STORAGE_ENDPOINT_URL: ${STORAGE_ENDPOINT_URL} + STORAGE_REGION: ${STORAGE_REGION} + STORAGE_BUCKET_NAME: ${STORAGE_BUCKET_NAME} + STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID} + STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT} + +services: + migrate: + image: ghcr.io/dihemoe/alisten/server:latest + command: migrate + environment: *alisten-env + depends_on: + postgres: + condition: service_healthy + required: false + rustfs: + condition: service_healthy + required: false + healthcheck: + disable: true + + serve: + image: ghcr.io/dihemoe/alisten/server:latest + command: serve + restart: unless-stopped + environment: + <<: *alisten-env + ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL} + ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS} + ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN} + STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL} + GOMAXPROCS: "2" + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + required: false + deploy: + replicas: 2 + stop_grace_period: 30s + + worker: + image: ghcr.io/dihemoe/alisten/worker:latest + command: worker + restart: unless-stopped + environment: *alisten-env + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + required: false + tmpfs: + - /tmp:size=${ALISTEN_WORKER_TMP_SIZE} + volumes: + - worker-cache:/var/cache/alisten + stop_grace_period: 30s + + web: + image: ghcr.io/dihemoe/alisten/web:latest + restart: unless-stopped + ports: + - "${ALISTEN_HOST}:${ALISTEN_PORT}:8080" + tmpfs: + - /tmp:size=64m + environment: + NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/alisten-templates + NGINX_ENVSUBST_OUTPUT_DIR: /tmp + STORAGE_BUCKET_NAME: ${STORAGE_BUCKET_NAME} + + postgres: + image: postgres:18-trixie + restart: unless-stopped + environment: + POSTGRES_USER: ${DATABASE_USERNAME} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD", "pg_isready", "--host=127.0.0.1", "--username=${DATABASE_USERNAME}", "--dbname=${DATABASE_NAME}"] + start_period: 30s + start_interval: 1s + interval: 10s + timeout: 3s + retries: 3 + + redis: + image: valkey/valkey:8-alpine + restart: unless-stopped + command: ["valkey-server", "--save", "", "--appendonly", "no", "--requirepass", "${REDIS_PASSWORD}"] + environment: + REDISCLI_AUTH: ${REDIS_PASSWORD} + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + start_period: 30s + start_interval: 1s + interval: 10s + timeout: 3s + retries: 3 + + rustfs: + image: rustfs/rustfs:1.0.3 + restart: unless-stopped + environment: + RUSTFS_ACCESS_KEY: ${STORAGE_ACCESS_KEY_ID} + RUSTFS_SECRET_KEY: ${STORAGE_SECRET_ACCESS_KEY} + RUSTFS_CONSOLE_ENABLE: "false" + RUSTFS_OBS_LOG_DIRECTORY: "" + volumes: + - rustfs-data:/data + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "--output", "/dev/null", "http://127.0.0.1:9000/health/ready"] + start_period: 30s + start_interval: 1s + interval: 10s + timeout: 3s + retries: 3 + +volumes: + postgres-data: + rustfs-data: + worker-cache: diff --git a/helm/alisten/Chart.yaml b/helm/alisten/Chart.yaml new file mode 100644 index 0000000..3408b3b --- /dev/null +++ b/helm/alisten/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: alisten +description: Alisten 的服务端、yt-dlp worker 和前端 +type: application +kubeVersion: ">=1.30.0-0" +version: 0.0.0 +appVersion: latest diff --git a/helm/alisten/templates/NOTES.txt b/helm/alisten/templates/NOTES.txt new file mode 100644 index 0000000..26c5465 --- /dev/null +++ b/helm/alisten/templates/NOTES.txt @@ -0,0 +1,10 @@ +Alisten 部署在 https://{{ .Values.domain }}。 +{{- if .Values.existingSecret }} + +后台需要 Secret {{ .Values.existingSecret }} 中有 ALISTEN_ADMIN_TOKEN。修改这个 Secret 之后重启 serve: + kubectl --namespace {{ .Release.Namespace }} rollout restart deployment/{{ include "alisten.fullname" . }}-serve +{{- else if not .Values.secrets.adminToken }} + +没有设置管理 token,后台不可用。设置之后 serve 会重建: + helm upgrade {{ .Release.Name }} helm/alisten --namespace {{ .Release.Namespace }} --reuse-values --set secrets.adminToken="$(openssl rand -hex 32)" +{{- end }} diff --git a/helm/alisten/templates/_helpers.tpl b/helm/alisten/templates/_helpers.tpl new file mode 100644 index 0000000..bcba80e --- /dev/null +++ b/helm/alisten/templates/_helpers.tpl @@ -0,0 +1,103 @@ +{{/* 资源名后面还要加上 -worker 之类的组件后缀,因此留出 8 个字符。 */}} +{{- define "alisten.fullname" -}} +{{- if contains .Chart.Name .Release.Name -}} +{{- .Release.Name | trunc 55 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 55 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "alisten.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "alisten.selectorLabels" -}} +app.kubernetes.io/name: {{ .root.Chart.Name }} +app.kubernetes.io/instance: {{ .root.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{- define "alisten.image" -}} +{{- printf "%s:%s" .image.repository (.image.tag | default .root.Chart.AppVersion) -}} +{{- end -}} + +{{- define "alisten.secretName" -}} +{{- .Values.existingSecret | default (include "alisten.fullname" .) -}} +{{- end -}} + +{{- define "alisten.domain" -}} +{{- $domain := required "domain 不能为空" .Values.domain -}} +{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" $domain) -}} +{{- fail "domain 必须是小写的主机名,不带协议、端口和路径" -}} +{{- end -}} +{{- $domain -}} +{{- end -}} + +{{- define "alisten.secretEnv" -}} +- name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ include "alisten.secretName" .root }} + key: {{ .name }} + {{- if .optional }} + optional: true + {{- end }} +{{- end -}} + +{{- define "alisten.databaseEnv" -}} +- name: DATABASE_HOST + value: {{ required "database.host 不能为空" .Values.database.host | quote }} +- name: DATABASE_PORT + value: {{ .Values.database.port | quote }} +- name: DATABASE_USERNAME + value: {{ required "database.username 不能为空" .Values.database.username | quote }} +{{ include "alisten.secretEnv" (dict "root" . "name" "DATABASE_PASSWORD") }} +- name: DATABASE_NAME + value: {{ required "database.name 不能为空" .Values.database.name | quote }} +- name: DATABASE_SSLMODE + value: {{ .Values.database.sslmode | quote }} +{{- end -}} + +{{- define "alisten.redisEnv" -}} +- name: REDIS_HOST + value: {{ required "redis.host 不能为空" .Values.redis.host | quote }} +- name: REDIS_PORT + value: {{ .Values.redis.port | quote }} +{{ include "alisten.secretEnv" (dict "root" . "name" "REDIS_PASSWORD" "optional" true) }} +- name: REDIS_DB + value: {{ .Values.redis.db | quote }} +{{- end -}} + +{{- define "alisten.storageEnv" -}} +- name: STORAGE_ENDPOINT_URL + value: {{ required "storage.endpointUrl 不能为空" .Values.storage.endpointUrl | quote }} +- name: STORAGE_REGION + value: {{ .Values.storage.region | quote }} +- name: STORAGE_BUCKET_NAME + value: {{ .Values.storage.bucketName | quote }} +{{ include "alisten.secretEnv" (dict "root" . "name" "STORAGE_ACCESS_KEY_ID") }} +{{ include "alisten.secretEnv" (dict "root" . "name" "STORAGE_SECRET_ACCESS_KEY") }} +{{- end -}} + +{{- define "alisten.podSecurityContext" -}} +runAsNonRoot: true +seccompProfile: + type: RuntimeDefault +{{- end -}} + +{{- define "alisten.containerSecurityContext" -}} +allowPrivilegeEscalation: false +readOnlyRootFilesystem: true +capabilities: + drop: [ALL] +{{- end -}} + +{{- define "alisten.secretChecksum" -}} +{{- if not .Values.existingSecret -}} +checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} +{{- end -}} +{{- end -}} diff --git a/helm/alisten/templates/hpa.yaml b/helm/alisten/templates/hpa.yaml new file mode 100644 index 0000000..93975cf --- /dev/null +++ b/helm/alisten/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.serve.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "alisten.fullname" . }}-serve + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: serve +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "alisten.fullname" . }}-serve + minReplicas: {{ .Values.serve.autoscaling.minReplicas }} + maxReplicas: {{ .Values.serve.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.serve.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/helm/alisten/templates/httproute.yaml b/helm/alisten/templates/httproute.yaml new file mode 100644 index 0000000..4b73f05 --- /dev/null +++ b/helm/alisten/templates/httproute.yaml @@ -0,0 +1,38 @@ +{{- if .Values.httpRoute.enabled }} +{{- if not .Values.httpRoute.parentRefs }} +{{- fail "启用 httpRoute 时要设置 httpRoute.parentRefs" }} +{{- end }} +{{- $fullname := include "alisten.fullname" . }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullname }} + labels: + {{- include "alisten.labels" . | nindent 4 }} +spec: + parentRefs: + {{- toYaml .Values.httpRoute.parentRefs | nindent 4 }} + hostnames: + - {{ include "alisten.domain" . | quote }} + rules: + - matches: + - path: + type: PathPrefix + value: /api + - path: + type: Exact + value: /ws + - path: + type: PathPrefix + value: /media + backendRefs: + - name: {{ $fullname }}-serve + port: 8080 + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ $fullname }}-web + port: 8080 +{{- end }} diff --git a/helm/alisten/templates/ingress.yaml b/helm/alisten/templates/ingress.yaml new file mode 100644 index 0000000..decbdab --- /dev/null +++ b/helm/alisten/templates/ingress.yaml @@ -0,0 +1,55 @@ +{{- if .Values.ingress.enabled }} +{{- $fullname := include "alisten.fullname" . }} +{{- $host := include "alisten.domain" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullname }} + labels: + {{- include "alisten.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tlsSecretName }} + tls: + - hosts: [{{ $host | quote }}] + secretName: {{ . }} + {{- end }} + rules: + - host: {{ $host | quote }} + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: {{ $fullname }}-serve + port: + name: http + - path: /ws + pathType: Prefix + backend: + service: + name: {{ $fullname }}-serve + port: + name: http + - path: /media + pathType: Prefix + backend: + service: + name: {{ $fullname }}-serve + port: + name: http + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullname }}-web + port: + name: http +{{- end }} diff --git a/helm/alisten/templates/networkpolicy.yaml b/helm/alisten/templates/networkpolicy.yaml new file mode 100644 index 0000000..3d6097b --- /dev/null +++ b/helm/alisten/templates/networkpolicy.yaml @@ -0,0 +1,48 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $v4 := list }} +{{- $v6 := list }} +{{- range .Values.networkPolicy.privateCIDRs }} +{{- if contains ":" . }} +{{- $v6 = append $v6 . }} +{{- else }} +{{- $v4 = append $v4 . }} +{{- end }} +{{- end }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "alisten.fullname" . }}-worker + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + podSelector: + matchLabels: + {{- include "alisten.selectorLabels" (dict "root" . "component" "worker") | nindent 6 }} + policyTypes: [Egress] + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + {{- toYaml $v4 | nindent 14 }} + - ipBlock: + cidr: ::/0 + except: + {{- toYaml $v6 | nindent 14 }} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- with .Values.networkPolicy.egress }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/alisten/templates/pdb.yaml b/helm/alisten/templates/pdb.yaml new file mode 100644 index 0000000..2e46b39 --- /dev/null +++ b/helm/alisten/templates/pdb.yaml @@ -0,0 +1,16 @@ +{{- $replicas := ternary .Values.serve.autoscaling.minReplicas .Values.serve.replicas .Values.serve.autoscaling.enabled }} +{{- /* 只有一个副本时,至少保留 1 个可用副本会让节点 drain 永远无法完成。 */}} +{{- if ge (int $replicas) 2 }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "alisten.fullname" . }}-serve + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: serve +spec: + minAvailable: 1 + selector: + matchLabels: + {{- include "alisten.selectorLabels" (dict "root" . "component" "serve") | nindent 6 }} +{{- end }} diff --git a/helm/alisten/templates/secret.yaml b/helm/alisten/templates/secret.yaml new file mode 100644 index 0000000..1471585 --- /dev/null +++ b/helm/alisten/templates/secret.yaml @@ -0,0 +1,19 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "alisten.fullname" . }} + labels: + {{- include "alisten.labels" . | nindent 4 }} +type: Opaque +stringData: + DATABASE_PASSWORD: {{ required "secrets.databasePassword 不能为空,或者设置 existingSecret" .Values.secrets.databasePassword | quote }} + STORAGE_ACCESS_KEY_ID: {{ required "secrets.storageAccessKeyId 不能为空,或者设置 existingSecret" .Values.secrets.storageAccessKeyId | quote }} + STORAGE_SECRET_ACCESS_KEY: {{ required "secrets.storageSecretAccessKey 不能为空,或者设置 existingSecret" .Values.secrets.storageSecretAccessKey | quote }} + {{- with .Values.secrets.redisPassword }} + REDIS_PASSWORD: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.adminToken }} + ALISTEN_ADMIN_TOKEN: {{ . | quote }} + {{- end }} +{{- end }} diff --git a/helm/alisten/templates/serve.yaml b/helm/alisten/templates/serve.yaml new file mode 100644 index 0000000..c08ef6d --- /dev/null +++ b/helm/alisten/templates/serve.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "alisten.fullname" . }}-serve + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: serve +spec: + {{- if not .Values.serve.autoscaling.enabled }} + replicas: {{ .Values.serve.replicas }} + {{- end }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "alisten.selectorLabels" (dict "root" . "component" "serve") | nindent 6 }} + template: + metadata: + labels: + {{- include "alisten.labels" . | nindent 8 }} + app.kubernetes.io/component: serve + {{- with include "alisten.secretChecksum" . }} + annotations: + {{- . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: false + enableServiceLinks: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "alisten.podSecurityContext" . | nindent 8 }} + # serve 退出最长需要 27 秒。 + terminationGracePeriodSeconds: 30 + initContainers: + - name: migrate + image: {{ include "alisten.image" (dict "root" . "image" .Values.serve.image) }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + args: [migrate] + env: + {{- include "alisten.databaseEnv" . | nindent 12 }} + {{- include "alisten.storageEnv" . | nindent 12 }} + - name: ALISTEN_LOG_LEVEL + value: {{ .Values.logLevel | quote }} + securityContext: + {{- include "alisten.containerSecurityContext" . | nindent 12 }} + resources: + {{- toYaml .Values.serve.resources | nindent 12 }} + containers: + - name: serve + image: {{ include "alisten.image" (dict "root" . "image" .Values.serve.image) }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + args: [serve] + ports: + - name: http + containerPort: 8080 + - name: ops + containerPort: 9090 + env: + - name: ALISTEN_PUBLIC_URL + value: {{ printf "https://%s" (include "alisten.domain" .) | quote }} + - name: ALISTEN_ALLOWED_ORIGINS + value: {{ .Values.allowedOrigins | quote }} + - name: STORAGE_PUBLIC_URL + value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }} + {{- include "alisten.databaseEnv" . | nindent 12 }} + {{- include "alisten.redisEnv" . | nindent 12 }} + {{- include "alisten.storageEnv" . | nindent 12 }} + {{- include "alisten.secretEnv" (dict "root" . "name" "ALISTEN_ADMIN_TOKEN" "optional" true) | nindent 12 }} + - name: ALISTEN_LOG_LEVEL + value: {{ .Values.logLevel | quote }} + {{- with .Values.otlpEndpoint }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ . | quote }} + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: ops + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: ops + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + {{- include "alisten.containerSecurityContext" . | nindent 12 }} + resources: + {{- toYaml .Values.serve.resources | nindent 12 }} diff --git a/helm/alisten/templates/servicemonitor.yaml b/helm/alisten/templates/servicemonitor.yaml new file mode 100644 index 0000000..96f5426 --- /dev/null +++ b/helm/alisten/templates/servicemonitor.yaml @@ -0,0 +1,24 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "alisten.fullname" . }} + labels: + {{- include "alisten.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: [serve, worker] + endpoints: + - port: ops + path: /metrics + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/helm/alisten/templates/services.yaml b/helm/alisten/templates/services.yaml new file mode 100644 index 0000000..19226da --- /dev/null +++ b/helm/alisten/templates/services.yaml @@ -0,0 +1,38 @@ +{{- range $component := list "serve" "web" }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "alisten.fullname" $ }}-{{ $component }} + labels: + {{- include "alisten.labels" $ | nindent 4 }} + app.kubernetes.io/component: {{ $component }} +spec: + selector: + {{- include "alisten.selectorLabels" (dict "root" $ "component" $component) | nindent 4 }} + ports: + - name: http + port: 8080 + targetPort: http + - name: ops + port: 9090 + targetPort: ops +{{- end }} +{{- if .Values.serviceMonitor.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "alisten.fullname" . }}-worker + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + clusterIP: None + selector: + {{- include "alisten.selectorLabels" (dict "root" . "component" "worker") | nindent 4 }} + ports: + - name: ops + port: 9090 + targetPort: ops +{{- end }} diff --git a/helm/alisten/templates/web.yaml b/helm/alisten/templates/web.yaml new file mode 100644 index 0000000..f64e223 --- /dev/null +++ b/helm/alisten/templates/web.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "alisten.fullname" . }}-web + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + replicas: {{ .Values.web.replicas }} + selector: + matchLabels: + {{- include "alisten.selectorLabels" (dict "root" . "component" "web") | nindent 6 }} + template: + metadata: + labels: + {{- include "alisten.labels" . | nindent 8 }} + app.kubernetes.io/component: web + spec: + automountServiceAccountToken: false + enableServiceLinks: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "alisten.podSecurityContext" . | nindent 8 }} + containers: + - name: web + image: {{ include "alisten.image" (dict "root" . "image" .Values.web.image) }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + ports: + - name: http + containerPort: 8080 + - name: ops + containerPort: 9090 + livenessProbe: + httpGet: + path: /healthz + port: ops + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: ops + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + lifecycle: + preStop: + sleep: + seconds: 5 + securityContext: + {{- include "alisten.containerSecurityContext" . | nindent 12 }} + resources: + {{- toYaml .Values.web.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: + sizeLimit: 64Mi diff --git a/helm/alisten/templates/worker.yaml b/helm/alisten/templates/worker.yaml new file mode 100644 index 0000000..6e1381d --- /dev/null +++ b/helm/alisten/templates/worker.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "alisten.fullname" . }}-worker + labels: + {{- include "alisten.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicas }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "alisten.selectorLabels" (dict "root" . "component" "worker") | nindent 6 }} + template: + metadata: + labels: + {{- include "alisten.labels" . | nindent 8 }} + app.kubernetes.io/component: worker + {{- with include "alisten.secretChecksum" . }} + annotations: + {{- . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: false + enableServiceLinks: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "alisten.podSecurityContext" . | nindent 8 }} + # worker 退出最长需要 27 秒。 + terminationGracePeriodSeconds: 30 + containers: + - name: worker + image: {{ include "alisten.image" (dict "root" . "image" .Values.worker.image) }} + imagePullPolicy: {{ .Values.imagePullPolicy }} + args: [worker] + ports: + - name: ops + containerPort: 9090 + env: + {{- include "alisten.databaseEnv" . | nindent 12 }} + {{- include "alisten.redisEnv" . | nindent 12 }} + {{- include "alisten.storageEnv" . | nindent 12 }} + - name: ALISTEN_LOG_LEVEL + value: {{ .Values.logLevel | quote }} + {{- with .Values.otlpEndpoint }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ . | quote }} + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: ops + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: ops + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + {{- include "alisten.containerSecurityContext" . | nindent 12 }} + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /var/cache/alisten + volumes: + - name: tmp + emptyDir: + sizeLimit: {{ .Values.worker.tmpSizeLimit | quote }} + - name: cache + emptyDir: {} diff --git a/helm/alisten/values.yaml b/helm/alisten/values.yaml new file mode 100644 index 0000000..13ac556 --- /dev/null +++ b/helm/alisten/values.yaml @@ -0,0 +1,96 @@ +domain: "" +allowedOrigins: "" +logLevel: info +otlpEndpoint: "" +database: + host: "" + port: 5432 + username: "" + name: "" + sslmode: prefer +redis: + host: "" + port: 6379 + db: 0 +storage: + endpointUrl: "" + region: us-east-1 + bucketName: alisten + publicUrl: "" +existingSecret: "" +secrets: + databasePassword: "" + redisPassword: "" + storageAccessKeyId: "" + storageSecretAccessKey: "" + adminToken: "" + +serve: + image: + repository: ghcr.io/dihemoe/alisten/server + tag: "" + replicas: 2 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "2" + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 80 + +worker: + image: + repository: ghcr.io/dihemoe/alisten/worker + tag: "" + replicas: 1 + tmpSizeLimit: 2Gi + resources: + requests: + cpu: 100m + memory: 256Mi + +web: + image: + repository: ghcr.io/dihemoe/alisten/web + tag: "" + replicas: 1 + resources: + requests: + cpu: 10m + memory: 16Mi + +imagePullPolicy: IfNotPresent +imagePullSecrets: [] + +ingress: + enabled: true + className: "" + annotations: {} + tlsSecretName: "" + +httpRoute: + enabled: false + parentRefs: [] + +serviceMonitor: + enabled: false + labels: {} + interval: 30s + +networkPolicy: + enabled: false + egress: [] + privateCIDRs: + - 10.0.0.0/8 + - 100.64.0.0/10 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.168.0.0/16 + - fc00::/7 + - fe80::/10 + - 64:ff9b::/96 + - 64:ff9b:1::/48 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..b2364ce --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,20 @@ +FROM --platform=$BUILDPLATFORM node:24-alpine AS build +ARG VERSION=dev +WORKDIR /src +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ . +RUN npm run build \ + && find dist -type f \( -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.svg' -o -name '*.json' -o -name '*.webmanifest' \) -exec gzip -9 -k {} + \ + && sed "s|@VERSION@|${VERSION}|" nginx/default.conf > /tmp/default.conf + +FROM nginxinc/nginx-unprivileged:1.30-alpine +ENV NGINX_ENTRYPOINT_QUIET_LOGS=1 +COPY web/nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=build /tmp/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/dist /usr/share/nginx/html +COPY web/nginx/templates /etc/nginx/alisten-templates +COPY LICENSE /usr/share/doc/alisten/ +EXPOSE 8080 9090 +HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:9090/healthz || exit 1 diff --git a/web/nginx/default.conf b/web/nginx/default.conf new file mode 100644 index 0000000..e67e418 --- /dev/null +++ b/web/nginx/default.conf @@ -0,0 +1,59 @@ +map $status $log_level { + ~^5 ERROR; + default INFO; +} + +log_format json escape=json + '{"time":"$time_iso8601","level":"$log_level","msg":"HTTP 请求",' + '"service":"alisten","component":"web","version":"@VERSION@",' + '"method":"$request_method","path":"$uri","status":$status,' + '"bytes":$body_bytes_sent,"duration_seconds":$request_time}'; + +server { + listen 8080; + root /usr/share/nginx/html; + server_tokens off; + access_log /dev/stdout json; + + gzip_static on; + gzip on; + gzip_vary on; + gzip_types text/css text/javascript application/javascript application/json application/manifest+json image/svg+xml; + + include /tmp/alisten.d/*.conf; + + location ~ ^/(?:admin|settings|history)?$ { + add_header Cache-Control "no-cache"; + try_files /index.html =404; + } + location ~ ^/r/[^/]+(?:/(?:queue|chat|search|playlists/[^/]+/[^/]+))?$ { + add_header Cache-Control "no-cache"; + try_files /index.html =404; + } + + location ~ ^/.+/$ { + return 404; + } + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + location / { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } +} + +server { + listen 9090; + access_log off; + + location = /healthz { + default_type text/plain; + return 200 "ok\n"; + } + location / { + return 404; + } +} diff --git a/web/nginx/nginx.conf b/web/nginx/nginx.conf new file mode 100644 index 0000000..dcbe5c4 --- /dev/null +++ b/web/nginx/nginx.conf @@ -0,0 +1,28 @@ +worker_processes 1; + +error_log /dev/stderr warn; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + include /etc/nginx/mime.types; + + types { + application/manifest+json webmanifest; + } + + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/web/nginx/templates/alisten.d/proxy.conf.template b/web/nginx/templates/alisten.d/proxy.conf.template new file mode 100644 index 0000000..52842ff --- /dev/null +++ b/web/nginx/templates/alisten.d/proxy.conf.template @@ -0,0 +1,36 @@ +# 主机名放在变量中,nginx 才会在每个请求时解析:serve 有两个副本且重启后地址会变,启动时上游也不必已经存在。 +resolver 127.0.0.11 valid=5s; +set $serve serve:8080; +set $rustfs rustfs:9000; +proxy_http_version 1.1; +absolute_redirect off; + +# default.conf 中有匹配以 / 结尾的路径的正则 location,只有带 ^~ 的前缀 location 才能优先于它。 +location ^~ /api/ { + # 不小于 serve 的请求体上限,请求体就不会写入临时文件,也就不会因此输出纯文本的 warn 日志。 + client_body_buffer_size 64k; + proxy_pass http://$serve; +} +location ^~ /media/ { + proxy_pass http://$serve; +} +# 空闲连接由 serve 断开,这里的超时只作兜底。 +location = /ws { + proxy_pass http://$serve; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; + proxy_send_timeout 1h; +} +# 只转发 link/ 下的读取请求:RustFS 的其他接口也在同一端口上。对象来自用户提交的链接且与页面同源,依靠这两个响应头阻止其中的脚本运行。 +# 用 return 而不用 limit_except:拒绝时不写纯文本的错误日志。关闭缓冲:缓冲时超出内存缓冲区的响应会写入 /tmp 的 tmpfs。 +location ^~ /storage/${STORAGE_BUCKET_NAME}/link/ { + if ($request_method !~ ^(GET|HEAD)$) { + return 405; + } + rewrite ^/storage(/.*)$ $1 break; + proxy_pass http://$rustfs; + proxy_buffering off; + add_header Content-Security-Policy "sandbox; default-src 'none'" always; + add_header X-Content-Type-Options nosniff always; +} diff --git a/worker.Dockerfile b/worker.Dockerfile new file mode 100644 index 0000000..5aaf454 --- /dev/null +++ b/worker.Dockerfile @@ -0,0 +1,42 @@ +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build +ARG TARGETOS TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +COPY cmd ./cmd +COPY db ./db +COPY internal ./internal +COPY protocol ./protocol +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags grpcnotrace \ + -ldflags "-s -w -X github.com/dihemoe/alisten/internal/bootstrap.version=$VERSION" \ + -o /out/alisten ./cmd/alisten + +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS tools +ARG TARGETARCH +ARG YTDLP_VERSION=2026.10.21 +ARG DENO_VERSION=v2.9.8 +RUN case "$TARGETARCH" in amd64) ytdlp=yt-dlp_linux deno=x86_64 ;; arm64) ytdlp=yt-dlp_linux_aarch64 deno=aarch64 ;; esac \ + && wget -q --tries=3 --timeout=30 -O /tmp/yt-dlp.zip "https://github.com/yt-dlp/yt-dlp/releases/download/$YTDLP_VERSION/$ytdlp.zip" \ + && wget -q --tries=3 --timeout=30 -O /tmp/deno.zip "https://github.com/denoland/deno/releases/download/$DENO_VERSION/deno-$deno-unknown-linux-gnu.zip" \ + && mkdir -p /out/opt/yt-dlp /out/usr/local/bin \ + && unzip -q /tmp/yt-dlp.zip -d /out/opt/yt-dlp \ + && unzip -q /tmp/deno.zip -d /out/usr/local/bin \ + && ln -s "/opt/yt-dlp/$ytdlp" /out/usr/local/bin/yt-dlp + +FROM debian:trixie-slim AS alisten-worker +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 65532 alisten \ + && useradd --system --uid 65532 --gid alisten --home-dir /var/cache/alisten --no-create-home --shell /usr/sbin/nologin alisten \ + && install -d -o alisten -g alisten /var/cache/alisten +COPY LICENSE /usr/share/doc/alisten/ +COPY --from=tools /out / +COPY --from=build /out/alisten /usr/local/bin/alisten +ENV XDG_CACHE_HOME=/var/cache/alisten +USER 65532:65532 +EXPOSE 9090 +HEALTHCHECK --start-period=30s --start-interval=1s --interval=10s --timeout=3s --retries=3 \ + CMD ["/usr/local/bin/alisten", "healthcheck"] +ENTRYPOINT ["/usr/local/bin/alisten"] +CMD ["worker"]