11 Commits

Author SHA1 Message Date
Den Piligrim 1b5ad11185 Merge branch 'main' into test 2026-06-11 11:16:07 +03:00
DenPiligrim a28d7a6954 Merge pull request #58 from denpiligrim/dp-fix
fix: bd sync
2026-06-08 23:35:25 +03:00
Den Piligrim 009506f915 fix: bd sync 2026-06-08 23:31:17 +03:00
DenPiligrim e84d77f248 Merge pull request #57 from denpiligrim/dp-fix
fix: insecure hy2
2026-06-08 22:48:26 +03:00
Den Piligrim f52e035d8a fix: insecure hy2 2026-06-08 22:47:25 +03:00
DenPiligrim 2e1cd86e99 Merge pull request #53 from zangetsuka/feature/happ-routing
Feature/happ routing
2026-06-04 10:48:09 +03:00
DenPiligrim 927a04f24a Merge pull request #55 from denpiligrim/dp-fix
...
2026-06-03 00:08:55 +03:00
Den Piligrim 600b398a5a ...
Co-Authored-By: Codex <267193182+codex@users.noreply.github.com>
2026-06-03 00:08:08 +03:00
root 97a8c41fc0 chore: update install.sh to use zangetsuka images with happ-routing tag 2026-06-01 12:57:42 +03:00
root b2cc357dca feat: add Happ routing support to subscriptions
- Add routingProfile field to InboundConfigDto
- Update Subscription entity type
- Add happ-routing handler in RotationService
- Add UI for happ-routing inbound type in SubscriptionsPage
2026-06-01 12:53:46 +03:00
root fc8fbc976f feat: add Happ routing support to subscriptions
- Add routingProfile field to InboundConfigDto
- Update Subscription entity type
- Add happ-routing handler in RotationService
- Add UI for happ-routing inbound type in SubscriptionsPage
2026-06-01 12:40:51 +03:00
12 changed files with 187 additions and 48 deletions
+1
View File
@@ -1,3 +1,4 @@
checker/ checker/
client/.env client/.env
client/coverage/ client/coverage/
backups/
+2 -2
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p> <p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.2.0-blue.svg) ![Downloads](https://img.shields.io/badge/downloads-5.1k-blue) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim) ![Version](https://img.shields.io/badge/version-2.2.0-blue.svg) ![Downloads](https://img.shields.io/badge/downloads-5.6k-blue) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 3DP-MANAGER # 3DP-MANAGER
@@ -11,7 +11,7 @@
**Поддержать проект** **Поддержать проект**
- Банковским переводом: - Банковским переводом:
- :credit_card: Карта МИР: `2204320436318077` - :credit_card: Карта МИР: [finance.ozon.ru](https://finance.ozon.ru/apps/sbp/ozonbankpay/019e46e5-c08d-734a-a056-6b340969760d)
- :credit_card: Карта MasterCard: `5395452209474530` - :credit_card: Карта MasterCard: `5395452209474530`
- На электронный кошелек: - На электронный кошелек:
- :moneybag: ЮМоney: `4100116897060652` - :moneybag: ЮМоney: `4100116897060652`
+69 -34
View File
@@ -83,6 +83,7 @@ interface InboundConfigUI {
name?: string; name?: string;
certificateFile?: string; certificateFile?: string;
keyFile?: string; keyFile?: string;
routingProfile?: string;
} }
interface Domain { interface Domain {
@@ -106,6 +107,7 @@ const CONNECTION_OPTIONS = [
'shadowsocks-tcp', 'shadowsocks-tcp',
'trojan-tcp-reality', 'trojan-tcp-reality',
'custom', 'custom',
'happ-routing',
]; ];
const generateId = () => Math.random().toString(36).substring(7); const generateId = () => Math.random().toString(36).substring(7);
@@ -221,17 +223,30 @@ export default function SubscriptionsPage() {
const createInbound = (type = 'vless-tcp-reality'): InboundConfigUI => { const createInbound = (type = 'vless-tcp-reality'): InboundConfigUI => {
const nodeId = getDefaultNodeId(); const nodeId = getDefaultNodeId();
// HAPP ROUTING - специальная обработка
if (type === 'happ-routing') {
return {
id: generateId(),
type,
port: '0',
sni: '',
link: '',
routingProfile: '',
};
}
const certDefaults = type === 'hysteria2-udp' ? getHysteriaCertDefaults(nodeId) : {}; const certDefaults = type === 'hysteria2-udp' ? getHysteriaCertDefaults(nodeId) : {};
return { return {
id: generateId(), id: generateId(),
type, type,
port: 'random', port: 'random',
sni: type === 'hysteria2-udp' ? '' : 'random', sni: type === 'hysteria2-udp' ? '' : 'random',
link: '', link: '',
nodeId, nodeId,
flag: getNodeFlag(nodeId), flag: getNodeFlag(nodeId),
name: '', name: '',
...certDefaults, ...certDefaults,
}; };
}; };
@@ -280,6 +295,7 @@ export default function SubscriptionsPage() {
relayServerId: item.relayServerId ? item.relayServerId.toString() : '', relayServerId: item.relayServerId ? item.relayServerId.toString() : '',
flag: item.flag || getNodeFlag(nodeId), flag: item.flag || getNodeFlag(nodeId),
name: item.name || '', name: item.name || '',
routingProfile: item.routingProfile || '',
certificateFile: certificateFile:
item.type === 'hysteria2-udp' item.type === 'hysteria2-udp'
? item.certificateFile || certDefaults.certificateFile ? item.certificateFile || certDefaults.certificateFile
@@ -323,7 +339,7 @@ export default function SubscriptionsPage() {
next.keyFile = next.keyFile || defaults.keyFile; next.keyFile = next.keyFile || defaults.keyFile;
} }
if (field === 'type' && value !== 'custom' && !next.nodeId) { if (field === 'type' && value !== 'custom' && value !== 'happ-routing' && !next.nodeId) {
next.nodeId = getDefaultNodeId(); next.nodeId = getDefaultNodeId();
next.flag = getNodeFlag(next.nodeId); next.flag = getNodeFlag(next.nodeId);
} }
@@ -363,7 +379,7 @@ export default function SubscriptionsPage() {
const handleSave = async () => { const handleSave = async () => {
const nextPortErrors = inbounds.reduce<Record<string, string>>((acc, inbound) => { const nextPortErrors = inbounds.reduce<Record<string, string>>((acc, inbound) => {
if (inbound.type !== 'custom' && !isValidPort(inbound.port)) { if (inbound.type !== 'custom' && inbound.type !== 'happ-routing' && !isValidPort(inbound.port)) {
acc[inbound.id] = 'Порт: число 1-65535 или random'; acc[inbound.id] = 'Порт: число 1-65535 или random';
} }
return acc; return acc;
@@ -385,29 +401,36 @@ export default function SubscriptionsPage() {
const payload = { const payload = {
name, name,
inboundsConfig: inbounds.map((inbound) => inboundsConfig: inbounds.map((inbound) => {
inbound.type === 'custom' if (inbound.type === 'custom') {
? { type: inbound.type, link: inbound.link } return { type: inbound.type, link: inbound.link };
: { }
type: inbound.type, if (inbound.type === 'happ-routing') {
port: inbound.port === 'random' ? 'random' : parseInt(inbound.port, 10), return {
sni: inbound.type === 'hysteria2-udp' ? undefined : inbound.sni, type: inbound.type,
nodeId: inbound.nodeId || undefined, routingProfile: inbound.routingProfile,
relayServerId: inbound.relayServerId };
? parseInt(inbound.relayServerId, 10) }
: undefined, return {
flag: inbound.flag || getNodeFlag(inbound.nodeId) || undefined, type: inbound.type,
name: inbound.name?.trim() || undefined, port: inbound.port === 'random' ? 'random' : parseInt(inbound.port, 10),
certificateFile: sni: inbound.type === 'hysteria2-udp' ? undefined : inbound.sni,
inbound.type === 'hysteria2-udp' nodeId: inbound.nodeId || undefined,
? inbound.certificateFile?.trim() || undefined relayServerId: inbound.relayServerId
: undefined, ? parseInt(inbound.relayServerId, 10)
keyFile: : undefined,
inbound.type === 'hysteria2-udp' flag: inbound.flag || getNodeFlag(inbound.nodeId) || undefined,
? inbound.keyFile?.trim() || undefined name: inbound.name?.trim() || undefined,
: undefined, certificateFile:
}, inbound.type === 'hysteria2-udp'
), ? inbound.certificateFile?.trim() || undefined
: undefined,
keyFile:
inbound.type === 'hysteria2-udp'
? inbound.keyFile?.trim() || undefined
: undefined,
};
}),
}; };
try { try {
@@ -735,6 +758,18 @@ export default function SubscriptionsPage() {
</FormControl> </FormControl>
{inbound.type === 'custom' ? ( {inbound.type === 'custom' ? (
<TextField size="small" label="Ссылка на подключение" placeholder="vless://..." value={inbound.link || ''} onChange={(e) => handleInboundChange(inbound.id, 'link', e.target.value)} sx={{ width: 460, flexShrink: 0 }} /> <TextField size="small" label="Ссылка на подключение" placeholder="vless://..." value={inbound.link || ''} onChange={(e) => handleInboundChange(inbound.id, 'link', e.target.value)} sx={{ width: 460, flexShrink: 0 }} />
) : inbound.type === 'happ-routing' ? (
<TextField
size="small"
label="Routing профиль (JSON или base64)"
placeholder='{"Name":"MyProfile","GlobalProxy":"true","DirectSites":["geosite:cn"]}'
value={inbound.routingProfile || ''}
onChange={(e) => handleInboundChange(inbound.id, 'routingProfile', e.target.value)}
multiline
rows={4}
sx={{ width: 460, flexShrink: 0 }}
helperText="Вставьте JSON профиль или готовую ссылку happ://routing/..."
/>
) : ( ) : (
<> <>
<FormControl size="small" sx={{ width: 170, flexShrink: 0 }}> <FormControl size="small" sx={{ width: 170, flexShrink: 0 }}>
+2
View File
@@ -26,6 +26,8 @@ services:
DB_USERNAME: ${POSTGRES_USER} DB_USERNAME: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD} DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB} DB_NAME: ${POSTGRES_DB}
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_LOGIN} ADMIN_LOGIN: ${ADMIN_LOGIN}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
+6 -2
View File
@@ -5,8 +5,8 @@ set -euo pipefail
# КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ # КОНФИГУРАЦИЯ И ПЕРЕМЕННЫЕ
################################# #################################
PROJECT_DIR="/opt/3dp-manager" PROJECT_DIR="/opt/3dp-manager"
DOCKER_USER="denpiligrim" DOCKER_USER="zangetsuka"
DOCKER_TAG="main" DOCKER_TAG="feature-happ-routing"
IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}" IMAGE_SERVER="ghcr.io/${DOCKER_USER}/3dp-manager-server:${DOCKER_TAG}"
IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}" IMAGE_CLIENT="ghcr.io/${DOCKER_USER}/3dp-manager-client:${DOCKER_TAG}"
@@ -469,6 +469,8 @@ services:
DB_USERNAME: admin DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS} DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager DB_NAME: 3dp_manager
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER} ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS} ADMIN_PASSWORD: ${ADMIN_PASS}
@@ -580,6 +582,8 @@ services:
DB_USERNAME: admin DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS} DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager DB_NAME: 3dp_manager
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER} ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS} ADMIN_PASSWORD: ${ADMIN_PASS}
+10 -1
View File
@@ -24,6 +24,9 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
import { SessionModule } from './session/session.module'; import { SessionModule } from './session/session.module';
import { Node } from './nodes/entities/node.entity'; import { Node } from './nodes/entities/node.entity';
import { NodesModule } from './nodes/nodes.module'; import { NodesModule } from './nodes/nodes.module';
import { AddNodesAndNodeRelations1765960000000 } from './migrations/1765960000000-add-nodes-and-node-relations';
import { AddNodeIpFlagAndInboundLabels1770000000000 } from './migrations/1770000000000-add-node-ip-flag-and-inbound-labels';
import { AddNodeDomain1770000000001 } from './migrations/1770000000001-add-node-domain';
@Module({ @Module({
imports: [ imports: [
@@ -44,7 +47,13 @@ import { NodesModule } from './nodes/nodes.module';
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node], entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node],
synchronize: true, migrations: [
AddNodesAndNodeRelations1765960000000,
AddNodeIpFlagAndInboundLabels1770000000000,
AddNodeDomain1770000000001,
],
synchronize: process.env.DB_SYNCHRONIZE !== 'false',
migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true',
}), }),
SessionModule, SessionModule,
XuiModule, XuiModule,
@@ -469,7 +469,6 @@ export class InboundBuilderService {
content: '', content: '',
dir: '', dir: '',
headers: {}, headers: {},
insecure: true,
rewriteHost: false, rewriteHost: false,
statusCode: 0, statusCode: 0,
type: 'proxy', type: 'proxy',
@@ -703,7 +702,6 @@ export class InboundBuilderService {
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password; const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
const finalmask = stream.finalmask?.udp?.[0]; const finalmask = stream.finalmask?.udp?.[0];
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('insecure', '1');
params.set('security', 'tls'); params.set('security', 'tls');
params.set('fp', 'chrome'); params.set('fp', 'chrome');
params.set('alpn', 'h3'); params.set('alpn', 'h3');
@@ -769,7 +767,6 @@ export class InboundBuilderService {
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('insecure', '1');
params.set('security', 'tls'); params.set('security', 'tls');
params.set('fp', 'chrome'); params.set('fp', 'chrome');
params.set('alpn', 'h3'); params.set('alpn', 'h3');
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNodeDomain1770000000001 implements MigrationInterface {
name = 'AddNodeDomain1770000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "node"
ADD COLUMN IF NOT EXISTS "domain" character varying
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "node" DROP COLUMN IF EXISTS "domain"`,
);
}
}
+1
View File
@@ -257,6 +257,7 @@ export class RotationService implements OnModuleInit {
sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni; sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni;
} }
// === 2. Обработка Hysteria2 === // === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') { if (type === 'hysteria2-udp') {
let port = 0; let port = 0;
@@ -82,6 +82,11 @@ export class InboundConfigDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
keyFile?: string; keyFile?: string;
// ⬇️ НОВОЕ ПОЛЕ ДЛЯ HAPP ROUTING ⬇️
@IsString()
@IsOptional()
routingProfile?: string;
} }
export class CreateSubscriptionDto { export class CreateSubscriptionDto {
@@ -40,6 +40,7 @@ export class Subscription {
name?: string; name?: string;
certificateFile?: string; certificateFile?: string;
keyFile?: string; keyFile?: string;
routingProfile?: string;
}>; }>;
@Column({ nullable: true }) @Column({ nullable: true })
+71 -5
View File
@@ -38,9 +38,8 @@ check_containers_running() {
# Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError") # Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError")
while IFS=$'\t' read -r container_name status; do while IFS=$'\t' read -r container_name status; do
if [ -n "$container_name" ] && [ -n "$status" ]; then if [ -n "$container_name" ] && [ -n "$status" ]; then
# Проверяем, что статус содержит Up/running/healthy/restarting # Restarting означает, что контейнер не смог стабильно запуститься.
# Up, Up 2 days, Up Less than a second, (healthy), running, restarting if ! echo "$status" | grep -qiE "^up|running|healthy"; then
if ! echo "$status" | grep -qiE "^up|running|healthy|restarting"; then
failed=1 failed=1
warn "Контейнер $container_name в статусе: $status" warn "Контейнер $container_name в статусе: $status"
fi fi
@@ -284,6 +283,52 @@ remove_hysteria_mount() {
mv "$tmp_file" "$compose_file" mv "$tmp_file" "$compose_file"
} }
ensure_safe_database_mode() {
local compose_file="$1"
[[ -f "$compose_file" ]] || return 0
local tmp_file
tmp_file="$(mktemp)"
awk '
/^[[:space:]]+DB_SYNCHRONIZE:/ { next }
/^[[:space:]]+DB_MIGRATIONS_RUN:/ { next }
{
print $0
if ($0 ~ /^[[:space:]]+DB_NAME:/) {
match($0, /^[[:space:]]+/)
indent = substr($0, RSTART, RLENGTH)
print indent "DB_SYNCHRONIZE: \"false\""
print indent "DB_MIGRATIONS_RUN: \"true\""
}
}
' "$compose_file" > "$tmp_file"
mv "$tmp_file" "$compose_file"
}
get_node_count() {
docker exec 3dp-postgres sh -c '
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "
SELECT CASE
WHEN to_regclass('\''public.node'\'') IS NULL THEN 0
ELSE (SELECT count(*) FROM node)
END
"
' 2>/dev/null | tr -d '[:space:]'
}
backup_database() {
local backup_dir="$PROJECT_DIR/backups"
BACKUP_FILE="$backup_dir/pre-update-$(date +%Y%m%d-%H%M%S).sql.gz"
mkdir -p "$backup_dir"
log "Создание резервной копии базы данных: $BACKUP_FILE"
docker exec 3dp-postgres sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip > "$BACKUP_FILE"
chmod 600 "$BACKUP_FILE"
}
need_root() { need_root() {
[[ $EUID -eq 0 ]] || die "Запускать только от root" [[ $EUID -eq 0 ]] || die "Запускать только от root"
} }
@@ -314,7 +359,11 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
################################# #################################
# CHECK AND FIX CREDENTIALS # CHECK AND FIX CREDENTIALS
################################# #################################
check_and_fix_credentials || true # Обновление не должно менять пароль уже инициализированной PostgreSQL:
# изменение только .env делает существующую базу недоступной.
if [[ ! -f ".env" ]]; then
die "Файл .env не найден. Обновление остановлено, чтобы не потерять доступ к существующей базе данных"
fi
################################# #################################
# FIX NGINX CONFIG # FIX NGINX CONFIG
@@ -322,6 +371,16 @@ check_and_fix_credentials || true
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf" ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf" ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
remove_hysteria_mount "$PROJECT_DIR/docker-compose.yml" remove_hysteria_mount "$PROJECT_DIR/docker-compose.yml"
ensure_safe_database_mode "$PROJECT_DIR/docker-compose.yml"
#################################
# BACKUP DATABASE
#################################
node_count_before="$(get_node_count)"
[[ "$node_count_before" =~ ^[0-9]+$ ]] || die "Не удалось проверить количество нод перед обновлением"
backup_database
backup_file="$BACKUP_FILE"
[[ -s "$backup_file" ]] || die "Не удалось создать резервную копию базы данных"
################################# #################################
# REBUILD BACKEND # REBUILD BACKEND
@@ -341,11 +400,18 @@ log "Пересоздание контейнеров..."
# Проверка: все ли контейнеры запустились # Проверка: все ли контейнеры запустились
if ! check_containers_running 60; then if ! check_containers_running 60; then
error "Не удалось запустить контейнеры. Логи:" warn "Не удалось запустить контейнеры. Логи:"
"${COMPOSE_CMD[@]}" logs --tail=50 "${COMPOSE_CMD[@]}" logs --tail=50
die "Обновление прервано из-за ошибки запуска контейнеров" die "Обновление прервано из-за ошибки запуска контейнеров"
fi fi
node_count_after="$(get_node_count)"
[[ "$node_count_after" =~ ^[0-9]+$ ]] || die "Не удалось проверить количество нод после обновления. Резервная копия: $backup_file"
if (( node_count_after < node_count_before )); then
"${COMPOSE_CMD[@]}" stop backend || true
die "Количество нод уменьшилось с $node_count_before до $node_count_after. Обновление остановлено, резервная копия: $backup_file"
fi
log "Очистка старых Docker-образов (освобождение места)..." log "Очистка старых Docker-образов (освобождение места)..."
docker image prune -f docker image prune -f