From be6d7560201c85b4891bbd22d3742ca02923670a Mon Sep 17 00:00:00 2001 From: okxlin <61420215+okxlin@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:43:39 +0800 Subject: [PATCH] Add Kaneo application Package Kaneo 2.9.9 and latest with a private PostgreSQL service, generated persistent credentials, hardened non-root runtimes, and first-admin deployment guidance. --- apps/kaneo/2.9.9/.env.sample | 10 ++ apps/kaneo/2.9.9/data.yml | 156 +++++++++++++++++++++++++ apps/kaneo/2.9.9/docker-compose.yml | 73 ++++++++++++ apps/kaneo/2.9.9/scripts/init.sh | 145 +++++++++++++++++++++++ apps/kaneo/2.9.9/scripts/uninstall.sh | 8 ++ apps/kaneo/2.9.9/scripts/upgrade.sh | 5 + apps/kaneo/README.md | 75 ++++++++++++ apps/kaneo/data.yml | 31 +++++ apps/kaneo/latest/.env.sample | 10 ++ apps/kaneo/latest/data.yml | 156 +++++++++++++++++++++++++ apps/kaneo/latest/docker-compose.yml | 73 ++++++++++++ apps/kaneo/latest/scripts/init.sh | 145 +++++++++++++++++++++++ apps/kaneo/latest/scripts/uninstall.sh | 8 ++ apps/kaneo/latest/scripts/upgrade.sh | 5 + apps/kaneo/logo-LICENSE.txt | 4 + apps/kaneo/logo.png | Bin 0 -> 12312 bytes 16 files changed, 904 insertions(+) create mode 100644 apps/kaneo/2.9.9/.env.sample create mode 100644 apps/kaneo/2.9.9/data.yml create mode 100644 apps/kaneo/2.9.9/docker-compose.yml create mode 100755 apps/kaneo/2.9.9/scripts/init.sh create mode 100755 apps/kaneo/2.9.9/scripts/uninstall.sh create mode 100755 apps/kaneo/2.9.9/scripts/upgrade.sh create mode 100644 apps/kaneo/README.md create mode 100644 apps/kaneo/data.yml create mode 100644 apps/kaneo/latest/.env.sample create mode 100644 apps/kaneo/latest/data.yml create mode 100644 apps/kaneo/latest/docker-compose.yml create mode 100755 apps/kaneo/latest/scripts/init.sh create mode 100755 apps/kaneo/latest/scripts/uninstall.sh create mode 100755 apps/kaneo/latest/scripts/upgrade.sh create mode 100644 apps/kaneo/logo-LICENSE.txt create mode 100644 apps/kaneo/logo.png diff --git a/apps/kaneo/2.9.9/.env.sample b/apps/kaneo/2.9.9/.env.sample new file mode 100644 index 000000000..a92b6d019 --- /dev/null +++ b/apps/kaneo/2.9.9/.env.sample @@ -0,0 +1,10 @@ +CONTAINER_NAME=kaneo +PANEL_APP_BIND_ADDRESS=127.0.0.1 +PANEL_APP_PORT_HTTP=5173 +KANEO_CLIENT_URL=http://localhost:5173 +DISABLE_REGISTRATION=false +POSTGRES_DB=kaneo +POSTGRES_USER=kaneo +POSTGRES_PASSWORD=generate +AUTH_SECRET=generate +APP_DATA_DIR=./data diff --git a/apps/kaneo/2.9.9/data.yml b/apps/kaneo/2.9.9/data.yml new file mode 100644 index 000000000..0fc03fec2 --- /dev/null +++ b/apps/kaneo/2.9.9/data.yml @@ -0,0 +1,156 @@ +additionalProperties: + formFields: + - default: 127.0.0.1 + edit: true + envKey: PANEL_APP_BIND_ADDRESS + labelEn: Bind Address + labelZh: 绑定地址 + label: + en: Bind Address + zh: 绑定地址 + zh-Hant: 綁定位址 + ja: バインドアドレス + ko: 바인드 주소 + ru: Адрес привязки + ms: Alamat ikatan + pt-br: Endereco de vinculacao + required: true + type: text + - default: 5173 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: HTTP Port + labelZh: HTTP 端口 + label: + en: HTTP Port + zh: HTTP 端口 + zh-Hant: HTTP 連接埠 + ja: HTTP ポート + ko: HTTP 포트 + ru: HTTP-порт + ms: Port HTTP + pt-br: Porta HTTP + required: true + rule: paramPort + type: number + - default: http://localhost:5173 + edit: true + envKey: KANEO_CLIENT_URL + labelEn: Public URL + labelZh: 公开访问地址 + label: + en: Public URL + zh: 公开访问地址 + zh-Hant: 公開存取網址 + ja: 公開 URL + ko: 공개 URL + ru: Публичный URL + ms: URL awam + pt-br: URL publica + required: true + type: text + - default: "false" + edit: true + envKey: DISABLE_REGISTRATION + labelEn: Disable Registration + labelZh: 禁止新用户注册 + label: + en: Disable Registration + zh: 禁止新用户注册 + zh-Hant: 停用新使用者註冊 + ja: 新規登録を無効化 + ko: 신규 등록 비활성화 + ru: Отключить регистрацию + ms: Nyahdayakan pendaftaran + pt-br: Desativar cadastro + required: true + type: select + values: + - label: "false" + value: "false" + - label: "true" + value: "true" + - default: kaneo + edit: true + envKey: POSTGRES_DB + labelEn: Database Name + labelZh: 数据库名称 + label: + en: Database Name + zh: 数据库名称 + zh-Hant: 資料庫名稱 + ja: データベース名 + ko: 데이터베이스 이름 + ru: Имя базы данных + ms: Nama pangkalan data + pt-br: Nome do banco de dados + random: true + required: true + rule: paramCommon + type: text + - default: kaneo + edit: true + envKey: POSTGRES_USER + labelEn: Database User + labelZh: 数据库用户 + label: + en: Database User + zh: 数据库用户 + zh-Hant: 資料庫使用者 + ja: データベースユーザー + ko: 데이터베이스 사용자 + ru: Пользователь базы данных + ms: Pengguna pangkalan data + pt-br: Usuario do banco de dados + random: true + required: true + rule: paramCommon + type: text + - default: generate + edit: true + envKey: POSTGRES_PASSWORD + labelEn: Database Password + labelZh: 数据库密码 + label: + en: Database Password + zh: 数据库密码 + zh-Hant: 資料庫密碼 + ja: データベースパスワード + ko: 데이터베이스 비밀번호 + ru: Пароль базы данных + ms: Kata laluan pangkalan data + pt-br: Senha do banco de dados + required: true + type: password + - default: generate + edit: true + envKey: AUTH_SECRET + labelEn: Authentication Secret + labelZh: 认证密钥 + label: + en: Authentication Secret + zh: 认证密钥 + zh-Hant: 驗證密鑰 + ja: 認証シークレット + ko: 인증 비밀 키 + ru: Секрет аутентификации + ms: Rahsia pengesahan + pt-br: Segredo de autenticacao + required: true + type: password + - default: ./data + edit: true + envKey: APP_DATA_DIR + labelEn: Data Directory + labelZh: 数据目录 + label: + en: Data Directory + zh: 数据目录 + zh-Hant: 資料目錄 + ja: データディレクトリ + ko: 데이터 디렉터리 + ru: Каталог данных + ms: Direktori data + pt-br: Diretorio de dados + required: true + type: text diff --git a/apps/kaneo/2.9.9/docker-compose.yml b/apps/kaneo/2.9.9/docker-compose.yml new file mode 100644 index 000000000..1f3efd73e --- /dev/null +++ b/apps/kaneo/2.9.9/docker-compose.yml @@ -0,0 +1,73 @@ +services: + kaneo-postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + container_name: ${CONTAINER_NAME}-postgres + restart: unless-stopped + user: "70:70" + networks: + - kaneo-internal + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - "${APP_DATA_DIR}/postgres:/var/lib/postgresql/data" + read_only: true + tmpfs: + - /tmp:mode=1777,size=64m + - /var/run/postgresql:uid=70,gid=70,mode=0755,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + labels: + createdBy: "Apps" + + kaneo: + image: ghcr.io/usekaneo/kaneo:2.9.9@sha256:d83479f41d437d5c35656f811f345a50357588422fc8d504cfde002756da688e + container_name: ${CONTAINER_NAME} + restart: unless-stopped + user: "1001:1001" + depends_on: + kaneo-postgres: + condition: service_healthy + networks: + - kaneo-internal + - 1panel-network + ports: + - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:5173" + environment: + KANEO_CLIENT_URL: ${KANEO_CLIENT_URL} + POSTGRES_HOST: kaneo-postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + AUTH_SECRET: ${AUTH_SECRET} + DISABLE_REGISTRATION: ${DISABLE_REGISTRATION} + tmpfs: + - /tmp:mode=1777,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5173/api/health"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 60s + labels: + createdBy: "Apps" + +networks: + kaneo-internal: + internal: true + 1panel-network: + external: true diff --git a/apps/kaneo/2.9.9/scripts/init.sh b/apps/kaneo/2.9.9/scripts/init.sh new file mode 100755 index 000000000..65b615bc8 --- /dev/null +++ b/apps/kaneo/2.9.9/scripts/init.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}" + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +read_env_value() { + local key="$1" + local value + value="$(sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1)" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s\n' "$value" +} + +set_env_value() { + local key="$1" + local value="$2" + local temp_file + temp_file="$(mktemp "${ROOT_DIR}/.kaneo-env.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { written = 0 } + $0 ~ "^" key "=" { + if (!written) { + print key "=" value + written = 1 + } + next + } + { print } + END { if (!written) print key "=" value } + ' "$ENV_FILE" > "$temp_file" + chmod 600 "$temp_file" + mv -f -- "$temp_file" "$ENV_FILE" +} + +validate_ipv4() { + local key="$1" + local value="$2" + local octet + local -a octets + [[ "$value" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || fail "$key must be an IPv4 address" + IFS=. read -r -a octets <<< "$value" + for octet in "${octets[@]}"; do + ((10#$octet <= 255)) || fail "$key contains an invalid IPv4 octet" + done +} + +validate_port() { + local key="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || fail "$key must be an integer" + ((10#$value >= 1 && 10#$value <= 65535)) || fail "$key must be between 1 and 65535" +} + +generate_alphanumeric() { + local length="$1" + local material + material="$(openssl rand -base64 96 | tr -dc A-Za-z0-9)" + [[ ${#material} -ge $length ]] || fail "unable to generate sufficient random material" + printf '%s\n' "${material:0:length}" +} + +[[ -f "$ENV_FILE" ]] || fail "$ENV_FILE not found" +[[ ! -L "$ENV_FILE" ]] || fail "$ENV_FILE must not be a symbolic link" +[[ "$(id -u)" -eq 0 ]] || fail "Kaneo init must run as root" +command -v openssl >/dev/null 2>&1 || fail "openssl is required to generate credentials" + +validate_ipv4 PANEL_APP_BIND_ADDRESS "$(read_env_value PANEL_APP_BIND_ADDRESS)" +validate_port PANEL_APP_PORT_HTTP "$(read_env_value PANEL_APP_PORT_HTTP)" + +client_url="$(read_env_value KANEO_CLIENT_URL)" +[[ "$client_url" =~ ^https?://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|localhost)(:([0-9]{1,5}))?$ ]] || + fail "KANEO_CLIENT_URL must be an HTTP(S) origin without a path" +[[ "$client_url" != *..* ]] || fail "KANEO_CLIENT_URL contains an invalid hostname" +if [[ -n "${BASH_REMATCH[4]:-}" ]]; then + validate_port KANEO_CLIENT_URL_PORT "${BASH_REMATCH[4]}" +fi + +disable_registration="$(read_env_value DISABLE_REGISTRATION)" +[[ "$disable_registration" == true || "$disable_registration" == false ]] || + fail "DISABLE_REGISTRATION must be true or false" + +postgres_db="$(read_env_value POSTGRES_DB)" +postgres_user="$(read_env_value POSTGRES_USER)" +[[ "$postgres_db" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]] || fail "POSTGRES_DB is invalid" +[[ "$postgres_user" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]] || fail "POSTGRES_USER is invalid" + +postgres_password="$(read_env_value POSTGRES_PASSWORD)" +if [[ -z "$postgres_password" || "$postgres_password" == generate ]]; then + postgres_password="$(generate_alphanumeric 48)" +fi +[[ "$postgres_password" =~ ^[A-Za-z0-9]{32,128}$ ]] || + fail "POSTGRES_PASSWORD must contain 32 to 128 alphanumeric characters" + +auth_secret="$(read_env_value AUTH_SECRET)" +if [[ -z "$auth_secret" || "$auth_secret" == generate ]]; then + auth_secret="$(openssl rand -hex 32)" +fi +[[ "$auth_secret" =~ ^[A-Fa-f0-9]{64,128}$ ]] || + fail "AUTH_SECRET must contain 64 to 128 hexadecimal characters" + +data_raw="$(read_env_value APP_DATA_DIR)" +[[ -n "$data_raw" && "$data_raw" != /* ]] || fail "APP_DATA_DIR must be a non-empty relative path" +case "$data_raw" in + *$'\n'* | *$'\r'* | *\\* | *:* | *'$'* | *'#'* | *'"'* | *"'"*) + fail "APP_DATA_DIR contains unsupported characters" + ;; +esac + +relative_data="${data_raw#./}" +[[ -n "$relative_data" ]] || fail "APP_DATA_DIR must not resolve to the version root" +current="$ROOT_DIR" +IFS=/ read -r -a components <<< "$relative_data" +for component in "${components[@]}"; do + [[ -n "$component" && "$component" != . && "$component" != .. ]] || fail "APP_DATA_DIR contains traversal" + current="$current/$component" + [[ ! -L "$current" ]] || fail "APP_DATA_DIR must not contain symbolic-link components" +done + +data_dir="$(realpath -m -- "$ROOT_DIR/$relative_data")" +case "$data_dir" in + "$ROOT_DIR"/*) ;; + *) fail "APP_DATA_DIR must stay inside the application version directory" ;; +esac + +install -d -m 0700 -- "$data_dir/postgres" +resolved_data="$(realpath -e -- "$data_dir")" +case "$resolved_data" in + "$ROOT_DIR"/*) ;; + *) fail "APP_DATA_DIR resolves outside the application version directory" ;; +esac +chown -R 70:70 -- "$resolved_data/postgres" +chmod 0700 -- "$resolved_data/postgres" + +set_env_value POSTGRES_PASSWORD "$postgres_password" +set_env_value AUTH_SECRET "$auth_secret" +chmod 600 "$ENV_FILE" diff --git a/apps/kaneo/2.9.9/scripts/uninstall.sh b/apps/kaneo/2.9.9/scripts/uninstall.sh new file mode 100755 index 000000000..5c199242b --- /dev/null +++ b/apps/kaneo/2.9.9/scripts/uninstall.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v docker-compose >/dev/null 2>&1; then + docker-compose down --remove-orphans +else + docker compose down --remove-orphans +fi diff --git a/apps/kaneo/2.9.9/scripts/upgrade.sh b/apps/kaneo/2.9.9/scripts/upgrade.sh new file mode 100755 index 000000000..5ec28d784 --- /dev/null +++ b/apps/kaneo/2.9.9/scripts/upgrade.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec "$SCRIPT_DIR/init.sh" diff --git a/apps/kaneo/README.md b/apps/kaneo/README.md new file mode 100644 index 000000000..bacb4588d --- /dev/null +++ b/apps/kaneo/README.md @@ -0,0 +1,75 @@ +# Kaneo + +## 产品介绍 + +Kaneo 是一个轻量级的开源项目管理平台,提供看板、任务、标签、评论、 +时间记录、实时更新和多种第三方集成。 + +## 主要功能 + +- 使用看板、任务、标签、评论和时间记录管理项目 +- 通过 WebSocket 实时同步项目变更 +- 支持 GitHub、Gitea、Slack、Discord、Telegram 和通用 Webhook 集成 +- 提供 API、MCP 接口和可选的对象存储图片附件 + +## 访问说明 + +- Web 端口默认只绑定 `127.0.0.1`。请在 1Panel 中配置 HTTPS 反向代理。 +- 安装时的公开访问地址必须填写完整的 HTTP(S) origin,例如 + `https://kaneo.example.com`,不要包含路径或末尾斜杠。 +- 第一个注册用户会成为实例管理员。确认管理员账号可用后,可在应用参数中 + 将“禁止新用户注册”设为 `true` 并重启应用。 + +## 安全与数据 + +- 安装脚本会一次生成数据库密码和认证密钥,并写入权限为 `0600` 的 + `.env`。升级和重启不会自动轮换。 +- Kaneo 以官方 UID/GID `1001:1001` 运行;PostgreSQL 以 `70:70` + 直接运行,不执行镜像内的 root/gosu 切换路径。 +- 两个容器都丢弃全部 Linux capabilities 并禁止权限提升。PostgreSQL + 仅连接内部 Docker 网络,不发布主机端口。 +- PostgreSQL 数据保存在安装版本目录下的 `data/postgres`。卸载不会删除 + 该目录;升级前请备份整个数据目录。 + +## Introduction + +Kaneo is a lightweight open-source project management platform with boards, +tasks, labels, comments, time tracking, live updates, and integrations. + +## Features + +- Manage projects with boards, tasks, labels, comments, and time tracking +- Synchronize project changes in real time over WebSockets +- Integrate with GitHub, Gitea, Slack, Discord, Telegram, and generic webhooks +- Use its API, MCP interface, and optional object storage for image attachments + +## Access And First Use + +- The web port binds to `127.0.0.1` by default. Publish it through the 1Panel + reverse proxy with HTTPS. +- Set Public URL to the complete HTTP(S) origin used by browsers, such as + `https://kaneo.example.com`. Do not include a path or trailing slash. +- The first registered user becomes the instance administrator. After that + account works, set Disable Registration to `true` and restart if public + signup is not required. + +## Security And Data + +- Installation generates the database password and authentication secret once + and stores them in the mode `0600` `.env`. Restart and upgrade preserve them. +- Kaneo runs as the upstream UID/GID `1001:1001`. PostgreSQL runs directly as + `70:70`, bypassing the image's root/gosu identity-switch path. +- Both services drop every Linux capability and prevent privilege escalation. + PostgreSQL is restricted to an internal Docker network and publishes no host + port. +- PostgreSQL data is stored in `data/postgres` below the installed version. + Uninstall preserves it. Back up the complete data directory before upgrades. +- Kaneo's root filesystem remains writable because the official startup script + replaces frontend runtime URLs and nginx discovery metadata in place. The + service remains non-root and can write only paths owned by its upstream user. + +## References + +- Source: +- Release: +- Self-hosting: diff --git a/apps/kaneo/data.yml b/apps/kaneo/data.yml new file mode 100644 index 000000000..5619e5eca --- /dev/null +++ b/apps/kaneo/data.yml @@ -0,0 +1,31 @@ +name: Kaneo +tags: + - 项目管理 +title: 轻量级开源项目管理平台 +description: 轻量级开源项目管理平台 +additionalProperties: + key: kaneo + name: Kaneo + tags: + - Project Management + shortDescZh: 轻量级开源项目管理平台 + shortDescEn: Lightweight open-source project management platform + description: + en: A lightweight open-source project management platform + zh: 一个轻量级开源项目管理平台 + zh-Hant: 一個輕量級開源專案管理平台 + ja: 軽量なオープンソースのプロジェクト管理プラットフォーム + ko: 가벼운 오픈 소스 프로젝트 관리 플랫폼 + ru: Легкая платформа управления проектами с открытым исходным кодом + ms: Platform pengurusan projek sumber terbuka yang ringan + pt-br: Plataforma leve de gerenciamento de projetos de codigo aberto + type: website + crossVersionUpdate: true + limit: 0 + recommend: 0 + website: https://kaneo.app/ + github: https://github.com/usekaneo/kaneo + document: https://github.com/usekaneo/kaneo#self-hosting + architectures: + - amd64 + - arm64 diff --git a/apps/kaneo/latest/.env.sample b/apps/kaneo/latest/.env.sample new file mode 100644 index 000000000..a92b6d019 --- /dev/null +++ b/apps/kaneo/latest/.env.sample @@ -0,0 +1,10 @@ +CONTAINER_NAME=kaneo +PANEL_APP_BIND_ADDRESS=127.0.0.1 +PANEL_APP_PORT_HTTP=5173 +KANEO_CLIENT_URL=http://localhost:5173 +DISABLE_REGISTRATION=false +POSTGRES_DB=kaneo +POSTGRES_USER=kaneo +POSTGRES_PASSWORD=generate +AUTH_SECRET=generate +APP_DATA_DIR=./data diff --git a/apps/kaneo/latest/data.yml b/apps/kaneo/latest/data.yml new file mode 100644 index 000000000..0fc03fec2 --- /dev/null +++ b/apps/kaneo/latest/data.yml @@ -0,0 +1,156 @@ +additionalProperties: + formFields: + - default: 127.0.0.1 + edit: true + envKey: PANEL_APP_BIND_ADDRESS + labelEn: Bind Address + labelZh: 绑定地址 + label: + en: Bind Address + zh: 绑定地址 + zh-Hant: 綁定位址 + ja: バインドアドレス + ko: 바인드 주소 + ru: Адрес привязки + ms: Alamat ikatan + pt-br: Endereco de vinculacao + required: true + type: text + - default: 5173 + edit: true + envKey: PANEL_APP_PORT_HTTP + labelEn: HTTP Port + labelZh: HTTP 端口 + label: + en: HTTP Port + zh: HTTP 端口 + zh-Hant: HTTP 連接埠 + ja: HTTP ポート + ko: HTTP 포트 + ru: HTTP-порт + ms: Port HTTP + pt-br: Porta HTTP + required: true + rule: paramPort + type: number + - default: http://localhost:5173 + edit: true + envKey: KANEO_CLIENT_URL + labelEn: Public URL + labelZh: 公开访问地址 + label: + en: Public URL + zh: 公开访问地址 + zh-Hant: 公開存取網址 + ja: 公開 URL + ko: 공개 URL + ru: Публичный URL + ms: URL awam + pt-br: URL publica + required: true + type: text + - default: "false" + edit: true + envKey: DISABLE_REGISTRATION + labelEn: Disable Registration + labelZh: 禁止新用户注册 + label: + en: Disable Registration + zh: 禁止新用户注册 + zh-Hant: 停用新使用者註冊 + ja: 新規登録を無効化 + ko: 신규 등록 비활성화 + ru: Отключить регистрацию + ms: Nyahdayakan pendaftaran + pt-br: Desativar cadastro + required: true + type: select + values: + - label: "false" + value: "false" + - label: "true" + value: "true" + - default: kaneo + edit: true + envKey: POSTGRES_DB + labelEn: Database Name + labelZh: 数据库名称 + label: + en: Database Name + zh: 数据库名称 + zh-Hant: 資料庫名稱 + ja: データベース名 + ko: 데이터베이스 이름 + ru: Имя базы данных + ms: Nama pangkalan data + pt-br: Nome do banco de dados + random: true + required: true + rule: paramCommon + type: text + - default: kaneo + edit: true + envKey: POSTGRES_USER + labelEn: Database User + labelZh: 数据库用户 + label: + en: Database User + zh: 数据库用户 + zh-Hant: 資料庫使用者 + ja: データベースユーザー + ko: 데이터베이스 사용자 + ru: Пользователь базы данных + ms: Pengguna pangkalan data + pt-br: Usuario do banco de dados + random: true + required: true + rule: paramCommon + type: text + - default: generate + edit: true + envKey: POSTGRES_PASSWORD + labelEn: Database Password + labelZh: 数据库密码 + label: + en: Database Password + zh: 数据库密码 + zh-Hant: 資料庫密碼 + ja: データベースパスワード + ko: 데이터베이스 비밀번호 + ru: Пароль базы данных + ms: Kata laluan pangkalan data + pt-br: Senha do banco de dados + required: true + type: password + - default: generate + edit: true + envKey: AUTH_SECRET + labelEn: Authentication Secret + labelZh: 认证密钥 + label: + en: Authentication Secret + zh: 认证密钥 + zh-Hant: 驗證密鑰 + ja: 認証シークレット + ko: 인증 비밀 키 + ru: Секрет аутентификации + ms: Rahsia pengesahan + pt-br: Segredo de autenticacao + required: true + type: password + - default: ./data + edit: true + envKey: APP_DATA_DIR + labelEn: Data Directory + labelZh: 数据目录 + label: + en: Data Directory + zh: 数据目录 + zh-Hant: 資料目錄 + ja: データディレクトリ + ko: 데이터 디렉터리 + ru: Каталог данных + ms: Direktori data + pt-br: Diretorio de dados + required: true + type: text diff --git a/apps/kaneo/latest/docker-compose.yml b/apps/kaneo/latest/docker-compose.yml new file mode 100644 index 000000000..08ba7662c --- /dev/null +++ b/apps/kaneo/latest/docker-compose.yml @@ -0,0 +1,73 @@ +services: + kaneo-postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + container_name: ${CONTAINER_NAME}-postgres + restart: unless-stopped + user: "70:70" + networks: + - kaneo-internal + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - "${APP_DATA_DIR}/postgres:/var/lib/postgresql/data" + read_only: true + tmpfs: + - /tmp:mode=1777,size=64m + - /var/run/postgresql:uid=70,gid=70,mode=0755,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + labels: + createdBy: "Apps" + + kaneo: + image: ghcr.io/usekaneo/kaneo:latest@sha256:d83479f41d437d5c35656f811f345a50357588422fc8d504cfde002756da688e + container_name: ${CONTAINER_NAME} + restart: unless-stopped + user: "1001:1001" + depends_on: + kaneo-postgres: + condition: service_healthy + networks: + - kaneo-internal + - 1panel-network + ports: + - "${PANEL_APP_BIND_ADDRESS}:${PANEL_APP_PORT_HTTP}:5173" + environment: + KANEO_CLIENT_URL: ${KANEO_CLIENT_URL} + POSTGRES_HOST: kaneo-postgres + POSTGRES_PORT: "5432" + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + AUTH_SECRET: ${AUTH_SECRET} + DISABLE_REGISTRATION: ${DISABLE_REGISTRATION} + tmpfs: + - /tmp:mode=1777,size=64m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5173/api/health"] + interval: 15s + timeout: 5s + retries: 8 + start_period: 60s + labels: + createdBy: "Apps" + +networks: + kaneo-internal: + internal: true + 1panel-network: + external: true diff --git a/apps/kaneo/latest/scripts/init.sh b/apps/kaneo/latest/scripts/init.sh new file mode 100755 index 000000000..65b615bc8 --- /dev/null +++ b/apps/kaneo/latest/scripts/init.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}" + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +read_env_value() { + local key="$1" + local value + value="$(sed -n "s/^${key}=//p" "$ENV_FILE" | tail -n 1)" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s\n' "$value" +} + +set_env_value() { + local key="$1" + local value="$2" + local temp_file + temp_file="$(mktemp "${ROOT_DIR}/.kaneo-env.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { written = 0 } + $0 ~ "^" key "=" { + if (!written) { + print key "=" value + written = 1 + } + next + } + { print } + END { if (!written) print key "=" value } + ' "$ENV_FILE" > "$temp_file" + chmod 600 "$temp_file" + mv -f -- "$temp_file" "$ENV_FILE" +} + +validate_ipv4() { + local key="$1" + local value="$2" + local octet + local -a octets + [[ "$value" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || fail "$key must be an IPv4 address" + IFS=. read -r -a octets <<< "$value" + for octet in "${octets[@]}"; do + ((10#$octet <= 255)) || fail "$key contains an invalid IPv4 octet" + done +} + +validate_port() { + local key="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || fail "$key must be an integer" + ((10#$value >= 1 && 10#$value <= 65535)) || fail "$key must be between 1 and 65535" +} + +generate_alphanumeric() { + local length="$1" + local material + material="$(openssl rand -base64 96 | tr -dc A-Za-z0-9)" + [[ ${#material} -ge $length ]] || fail "unable to generate sufficient random material" + printf '%s\n' "${material:0:length}" +} + +[[ -f "$ENV_FILE" ]] || fail "$ENV_FILE not found" +[[ ! -L "$ENV_FILE" ]] || fail "$ENV_FILE must not be a symbolic link" +[[ "$(id -u)" -eq 0 ]] || fail "Kaneo init must run as root" +command -v openssl >/dev/null 2>&1 || fail "openssl is required to generate credentials" + +validate_ipv4 PANEL_APP_BIND_ADDRESS "$(read_env_value PANEL_APP_BIND_ADDRESS)" +validate_port PANEL_APP_PORT_HTTP "$(read_env_value PANEL_APP_PORT_HTTP)" + +client_url="$(read_env_value KANEO_CLIENT_URL)" +[[ "$client_url" =~ ^https?://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|localhost)(:([0-9]{1,5}))?$ ]] || + fail "KANEO_CLIENT_URL must be an HTTP(S) origin without a path" +[[ "$client_url" != *..* ]] || fail "KANEO_CLIENT_URL contains an invalid hostname" +if [[ -n "${BASH_REMATCH[4]:-}" ]]; then + validate_port KANEO_CLIENT_URL_PORT "${BASH_REMATCH[4]}" +fi + +disable_registration="$(read_env_value DISABLE_REGISTRATION)" +[[ "$disable_registration" == true || "$disable_registration" == false ]] || + fail "DISABLE_REGISTRATION must be true or false" + +postgres_db="$(read_env_value POSTGRES_DB)" +postgres_user="$(read_env_value POSTGRES_USER)" +[[ "$postgres_db" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]] || fail "POSTGRES_DB is invalid" +[[ "$postgres_user" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]] || fail "POSTGRES_USER is invalid" + +postgres_password="$(read_env_value POSTGRES_PASSWORD)" +if [[ -z "$postgres_password" || "$postgres_password" == generate ]]; then + postgres_password="$(generate_alphanumeric 48)" +fi +[[ "$postgres_password" =~ ^[A-Za-z0-9]{32,128}$ ]] || + fail "POSTGRES_PASSWORD must contain 32 to 128 alphanumeric characters" + +auth_secret="$(read_env_value AUTH_SECRET)" +if [[ -z "$auth_secret" || "$auth_secret" == generate ]]; then + auth_secret="$(openssl rand -hex 32)" +fi +[[ "$auth_secret" =~ ^[A-Fa-f0-9]{64,128}$ ]] || + fail "AUTH_SECRET must contain 64 to 128 hexadecimal characters" + +data_raw="$(read_env_value APP_DATA_DIR)" +[[ -n "$data_raw" && "$data_raw" != /* ]] || fail "APP_DATA_DIR must be a non-empty relative path" +case "$data_raw" in + *$'\n'* | *$'\r'* | *\\* | *:* | *'$'* | *'#'* | *'"'* | *"'"*) + fail "APP_DATA_DIR contains unsupported characters" + ;; +esac + +relative_data="${data_raw#./}" +[[ -n "$relative_data" ]] || fail "APP_DATA_DIR must not resolve to the version root" +current="$ROOT_DIR" +IFS=/ read -r -a components <<< "$relative_data" +for component in "${components[@]}"; do + [[ -n "$component" && "$component" != . && "$component" != .. ]] || fail "APP_DATA_DIR contains traversal" + current="$current/$component" + [[ ! -L "$current" ]] || fail "APP_DATA_DIR must not contain symbolic-link components" +done + +data_dir="$(realpath -m -- "$ROOT_DIR/$relative_data")" +case "$data_dir" in + "$ROOT_DIR"/*) ;; + *) fail "APP_DATA_DIR must stay inside the application version directory" ;; +esac + +install -d -m 0700 -- "$data_dir/postgres" +resolved_data="$(realpath -e -- "$data_dir")" +case "$resolved_data" in + "$ROOT_DIR"/*) ;; + *) fail "APP_DATA_DIR resolves outside the application version directory" ;; +esac +chown -R 70:70 -- "$resolved_data/postgres" +chmod 0700 -- "$resolved_data/postgres" + +set_env_value POSTGRES_PASSWORD "$postgres_password" +set_env_value AUTH_SECRET "$auth_secret" +chmod 600 "$ENV_FILE" diff --git a/apps/kaneo/latest/scripts/uninstall.sh b/apps/kaneo/latest/scripts/uninstall.sh new file mode 100755 index 000000000..5c199242b --- /dev/null +++ b/apps/kaneo/latest/scripts/uninstall.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v docker-compose >/dev/null 2>&1; then + docker-compose down --remove-orphans +else + docker compose down --remove-orphans +fi diff --git a/apps/kaneo/latest/scripts/upgrade.sh b/apps/kaneo/latest/scripts/upgrade.sh new file mode 100755 index 000000000..5ec28d784 --- /dev/null +++ b/apps/kaneo/latest/scripts/upgrade.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +exec "$SCRIPT_DIR/init.sh" diff --git a/apps/kaneo/logo-LICENSE.txt b/apps/kaneo/logo-LICENSE.txt new file mode 100644 index 000000000..4e2666ad5 --- /dev/null +++ b/apps/kaneo/logo-LICENSE.txt @@ -0,0 +1,4 @@ +Source: https://github.com/usekaneo/kaneo/blob/v2.9.9/apps/site/public/logo-512.png +License: MIT +Copyright: Copyright (c) 2024 Andrej Acevski +The source image is used without changing its design. diff --git a/apps/kaneo/logo.png b/apps/kaneo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..94b1be8d0e5520ebe057f570016074fb92662464 GIT binary patch literal 12312 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 F|2GNR*?9l} literal 0 HcmV?d00001