merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -0,0 +1,97 @@
<#
.SYNOPSIS
Ensures the branch-isolated local PostgreSQL databases exist.
.DESCRIPTION
The main and v2 branches have independent migration histories. They must never
share one schema_migrations row. This helper creates telesrv_main and
telesrv_v2 in the local Compose PostgreSQL container without modifying or
deleting the legacy telesrv database.
Optional template parameters are intended for a one-time local split when an
existing database snapshot should be preserved. They are only used when the
target database does not already exist.
#>
[CmdletBinding()]
param(
[string]$PostgresContainer = "telesrv-postgres",
[string]$DbUser = "telesrv",
[string]$MainTemplate,
[string]$V2Template
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-SafeIdentifier {
param([string]$Name, [string]$Value)
if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') {
throw "$Name must be a PostgreSQL identifier containing only letters, digits, and underscores: '$Value'"
}
}
function Invoke-Docker {
param([string[]]$Arguments)
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & docker @Arguments 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $oldErrorActionPreference
}
$text = ($output | ForEach-Object { $_.ToString() }) -join "`n"
if ($exitCode -ne 0) {
throw "docker $($Arguments -join ' ') failed with exit code ${exitCode}:`n$text"
}
return $text.Trim()
}
function Test-DatabaseExists {
param([string]$Database)
Assert-SafeIdentifier "database" $Database
$result = Invoke-Docker @(
"exec", $PostgresContainer,
"psql", "-U", $DbUser, "-d", "postgres",
"-v", "ON_ERROR_STOP=1", "-At", "-c",
"SELECT 1 FROM pg_database WHERE datname = '$Database';"
)
return $result -eq "1"
}
function Ensure-Database {
param([string]$Database, [string]$Template)
if (Test-DatabaseExists $Database) {
Write-Host "[ok] PostgreSQL database already exists: $Database"
return
}
$args = @("exec", $PostgresContainer, "createdb", "-U", $DbUser, "-O", $DbUser)
if (-not [string]::IsNullOrWhiteSpace($Template)) {
Assert-SafeIdentifier "template database" $Template
if (-not (Test-DatabaseExists $Template)) {
throw "template database does not exist: $Template"
}
$args += @("-T", $Template)
}
$args += $Database
Invoke-Docker $args | Out-Null
$templateSuffix = ""
if (-not [string]::IsNullOrWhiteSpace($Template)) {
$templateSuffix = " (template: $Template)"
}
Write-Host "[ok] created PostgreSQL database: $Database$templateSuffix"
}
Assert-SafeIdentifier "database user" $DbUser
if ($PostgresContainer -notmatch '^[A-Za-z0-9_.-]+$') {
throw "invalid Docker container name: '$PostgresContainer'"
}
$running = Invoke-Docker @("inspect", "-f", "{{.State.Running}}", $PostgresContainer)
if ($running -ne "true") {
throw "PostgreSQL container is not running: $PostgresContainer"
}
Ensure-Database "telesrv_main" $MainTemplate
Ensure-Database "telesrv_v2" $V2Template

220
scripts/new-docker-env.ps1 Normal file
View file

@ -0,0 +1,220 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$AdvertiseIP,
[Parameter()]
[string]$PublicBaseURL = "",
[Parameter()]
[string]$PublicWebBaseURL = "",
[Parameter()]
[string]$AdminBindIP = "127.0.0.1",
[Parameter()]
[switch]$HostNetwork,
[Parameter()]
[switch]$BridgeNetwork,
[Parameter()]
[switch]$AllowInsecureDevelopmentAuth
)
$ErrorActionPreference = "Stop"
if ($HostNetwork -and $BridgeNetwork) {
throw "HostNetwork and BridgeNetwork are mutually exclusive."
}
$parsedIP = $null
if (-not [System.Net.IPAddress]::TryParse($AdvertiseIP, [ref]$parsedIP)) {
throw "AdvertiseIP must be an IPv4 or IPv6 address, not a DNS name."
}
$isLoopback = [System.Net.IPAddress]::IsLoopback($parsedIP)
$isIPv6 = $parsedIP.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6
$parsedAdminBindIP = $null
if (-not [System.Net.IPAddress]::TryParse($AdminBindIP, [ref]$parsedAdminBindIP)) {
throw "AdminBindIP must be an IPv4 or IPv6 address."
}
if ([string]::IsNullOrWhiteSpace($PublicBaseURL)) {
if (-not $isLoopback) {
throw "PublicBaseURL is required when AdvertiseIP is not loopback."
}
$loopbackHost = if ($isIPv6) { "[::1]" } else { "127.0.0.1" }
$PublicBaseURL = "http://${loopbackHost}:2401"
}
if ([string]::IsNullOrWhiteSpace($PublicWebBaseURL)) {
$PublicWebBaseURL = $PublicBaseURL
}
function Assert-HTTPURL {
param([string]$Name, [string]$Value)
$uri = $null
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri) -or
($uri.Scheme -ne "http" -and $uri.Scheme -ne "https") -or
-not [string]::IsNullOrEmpty($uri.UserInfo)) {
throw "$Name must be an absolute HTTP(S) URL without embedded credentials."
}
}
Assert-HTTPURL -Name "PublicBaseURL" -Value $PublicBaseURL
Assert-HTTPURL -Name "PublicWebBaseURL" -Value $PublicWebBaseURL
if (-not $isLoopback -and -not $AllowInsecureDevelopmentAuth) {
throw "Internet/LAN development-code auth requires AllowInsecureDevelopmentAuth; for production generate on loopback, then configure the webhook provider before startup."
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$dockerDir = Join-Path $repoRoot "deploy\docker"
$templatePath = Join-Path $dockerDir ".env.example"
$outputPath = Join-Path $dockerDir ".env"
if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) {
throw "Docker environment template not found: $templatePath"
}
if (Test-Path -LiteralPath $outputPath) {
throw "$outputPath already exists. Initialization never overwrites live credentials."
}
function New-HexSecret {
param([int]$Bytes = 32)
$buffer = New-Object byte[] $Bytes
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($buffer)
}
finally {
$generator.Dispose()
}
return [BitConverter]::ToString($buffer).Replace("-", "").ToLowerInvariant()
}
function Get-GitValue {
param([string[]]$GitArguments)
try {
$value = & git -C $repoRoot @GitArguments 2>$null
if ($LASTEXITCODE -eq 0) { return ($value | Select-Object -First 1).Trim() }
}
catch {}
return "unknown"
}
$publicBindIP = if ($isIPv6) { "::" } else { "0.0.0.0" }
$localBindIP = if ($isIPv6) { "::1" } else { "127.0.0.1" }
if ($isLoopback) {
$publicBindIP = $parsedIP.ToString()
$localBindIP = $parsedIP.ToString()
}
elseif (([Uri]$PublicBaseURL).Scheme -eq "http") {
$publicLinkIP = $null
$publicLinkHost = ([Uri]$PublicBaseURL).Host.Trim([char[]]"[]")
if ([System.Net.IPAddress]::TryParse($publicLinkHost, [ref]$publicLinkIP) -and $publicLinkIP.Equals($parsedIP)) {
$localBindIP = $parsedIP.ToString()
}
}
$adminHealthIP = $parsedAdminBindIP.ToString()
if ($adminHealthIP -eq "0.0.0.0") { $adminHealthIP = "127.0.0.1" }
if ($adminHealthIP -eq "::") { $adminHealthIP = "::1" }
$publicListenHost = if ($publicBindIP.Contains(":")) { "[${publicBindIP}]" } else { $publicBindIP }
$localListenHost = if ($localBindIP.Contains(":")) { "[${localBindIP}]" } else { $localBindIP }
$serverHealthURLHost = $localListenHost
$adminListenIP = $parsedAdminBindIP.ToString()
$adminListenHost = if ($adminListenIP.Contains(":")) { "[${adminListenIP}]" } else { $adminListenIP }
$turnEnabled = (-not $isIPv6).ToString().ToLowerInvariant()
$turnAdvertiseIP = if ($isIPv6) { "127.0.0.1" } else { $parsedIP.ToString() }
$rtmpHost = if ($isIPv6) { "[$($parsedIP.ToString())]" } else { $parsedIP.ToString() }
$postgresPassword = New-HexSecret 24
$treeState = "unknown"
try {
$treeOutput = & git -C $repoRoot status --porcelain 2>$null
if ($LASTEXITCODE -eq 0) { $treeState = if (@($treeOutput).Count -gt 0) { "dirty" } else { "clean" } }
}
catch {}
$values = [ordered]@{
TELESRV_BUILD_COMMIT = Get-GitValue @("rev-parse", "HEAD")
TELESRV_BUILD_BRANCH = Get-GitValue @("rev-parse", "--abbrev-ref", "HEAD")
TELESRV_BUILD_TREE_STATE = $treeState
TELESRV_BUILD_DATE = [DateTime]::UtcNow.ToString("o")
POSTGRES_PASSWORD = $postgresPassword
TELESRV_POSTGRES_DSN = "postgres://telesrv:${postgresPassword}@127.0.0.1:15432/telesrv_main?sslmode=disable"
TELESRV_REDIS_PASSWORD = New-HexSecret 32
TELESRV_ADMIN_API_TOKEN = New-HexSecret 32
TELESRV_ADMIN_UI_PASSWORD = New-HexSecret 24
TELESRV_ADMIN_SESSION_KEY = New-HexSecret 32
TELESRV_TURN_SECRET = New-HexSecret 32
TELESRV_OTP_WEBHOOK_SECRET = New-HexSecret 32
TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH = ($isLoopback -or $AllowInsecureDevelopmentAuth).ToString().ToLowerInvariant()
TELESRV_ADVERTISE_IP = $parsedIP.ToString()
TELESRV_PUBLIC_BASE_URL = $PublicBaseURL
TELESRV_PUBLIC_WEB_BASE_URL = $PublicWebBaseURL
TELESRV_SERVER_HOST_NETWORK = (-not $BridgeNetwork).ToString().ToLowerInvariant()
TELESRV_SFU_ADVERTISE_IP = $parsedIP.ToString()
TELESRV_TURN_ENABLE = $turnEnabled
TELESRV_TURN_ADVERTISE_IP = $turnAdvertiseIP
TELESRV_LIVESTREAM_RTMP_URL = "rtmp://${rtmpHost}:2400/live"
TELESRV_PUBLIC_BIND_IP = $publicBindIP
TELESRV_PUBLIC_LISTEN_HOST = $publicListenHost
TELESRV_LOCAL_BIND_IP = $localBindIP
TELESRV_LOCAL_LISTEN_HOST = $localListenHost
TELESRV_SERVER_HEALTH_IP = $localBindIP
TELESRV_SERVER_HEALTH_URL_HOST = $serverHealthURLHost
TELESRV_ADMIN_BIND_IP = $adminListenIP
TELESRV_ADMIN_LISTEN_HOST = $adminListenHost
TELESRV_ADMIN_HEALTH_IP = $adminHealthIP
}
$content = [IO.File]::ReadAllText($templatePath)
foreach ($entry in $values.GetEnumerator()) {
$pattern = "(?m)^$([Regex]::Escape($entry.Key))=.*$"
if (-not [Regex]::IsMatch($content, $pattern)) { throw "Template is missing $($entry.Key)." }
$replacement = ("{0}={1}" -f $entry.Key, $entry.Value).Replace('$', '$$')
$content = [Regex]::Replace($content, $pattern, $replacement)
}
function Protect-SecretFile {
param([string]$Path)
if ($env:OS -eq "Windows_NT") {
$owner = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$acl.SetOwner($owner)
$identities = @(
$owner,
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18")),
(New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544"))
)
foreach ($identity in $identities) {
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$identity,
[System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow
)
[void]$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $Path -AclObject $acl
return
}
& chmod 600 -- $Path
if ($LASTEXITCODE -ne 0) { throw "chmod 600 failed for $Path" }
}
if ($PSCmdlet.ShouldProcess($outputPath, "write owner-only Docker deployment environment")) {
$temporaryPath = "$outputPath.tmp.$PID"
try {
[IO.File]::WriteAllText($temporaryPath, $content, (New-Object Text.UTF8Encoding($false)))
Protect-SecretFile -Path $temporaryPath
Move-Item -LiteralPath $temporaryPath -Destination $outputPath
}
finally {
if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force }
}
Write-Host "Created $outputPath with generated deployment credentials."
}

268
scripts/new-docker-env.sh Executable file
View file

@ -0,0 +1,268 @@
#!/bin/sh
set -eu
umask 077
usage() {
cat <<'EOF'
Usage: ./scripts/new-docker-env.sh --advertise-ip IP [options]
Options:
--public-base-url URL
--public-web-base-url URL
--admin-bind-ip IP
--host-network Bind the monolith's media sockets directly on the host (default).
--bridge-network Publish a bounded TURN relay range through Docker.
--allow-insecure-development-auth
--output PATH
--help
EOF
}
die() {
printf 'new-docker-env: %s\n' "$*" >&2
exit 1
}
validate_http_url() {
name=$1
value=$2
case "$value" in
*[[:space:]]*) die "$name must not contain whitespace" ;;
esac
case "$value" in
http://*|https://*) ;;
*) die "$name must be an absolute HTTP(S) URL" ;;
esac
authority=${value#*://}
authority=${authority%%/*}
authority=${authority%%\?*}
authority=${authority%%\#*}
[ -n "$authority" ] || die "$name must include a host"
case "$authority" in
*@*) die "$name must not contain embedded credentials" ;;
esac
}
url_host() {
value=$1
authority=${value#*://}
authority=${authority%%/*}
authority=${authority%%\?*}
authority=${authority%%\#*}
case "$authority" in
\[*\]*) host=${authority#\[}; host=${host%%\]*} ;;
*) host=${authority%%:*} ;;
esac
printf '%s\n' "$host"
}
validate_ipv4() {
awk -F. '
NF != 4 { exit 1 }
{ for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i < 0 || $i > 255) exit 1 }
' <<EOF
$1
EOF
}
validate_ipv6() {
case "$1" in *[!0-9A-Fa-f:.]*|'') return 1 ;; esac
if command -v perl >/dev/null 2>&1; then
perl -MSocket=AF_INET6,inet_pton -e 'exit inet_pton(AF_INET6, $ARGV[0]) ? 0 : 1' "$1"
return
fi
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import ipaddress,sys; ipaddress.IPv6Address(sys.argv[1])' "$1"
return
fi
die "validating an IPv6 address requires perl or python3"
}
random_hex() {
openssl rand -hex "$1"
}
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
template_path="$repo_root/deploy/docker/.env.example"
output_path="$repo_root/deploy/docker/.env"
advertise_ip=
public_base_url=
public_web_base_url=
admin_bind_ip=127.0.0.1
server_host_network=true
allow_insecure=false
while [ "$#" -gt 0 ]; do
case "$1" in
--advertise-ip)
[ "$#" -ge 2 ] || die "$1 requires a value"
advertise_ip=$2
shift 2
;;
--public-base-url)
[ "$#" -ge 2 ] || die "$1 requires a value"
public_base_url=$2
shift 2
;;
--public-web-base-url)
[ "$#" -ge 2 ] || die "$1 requires a value"
public_web_base_url=$2
shift 2
;;
--admin-bind-ip)
[ "$#" -ge 2 ] || die "$1 requires a value"
admin_bind_ip=$2
shift 2
;;
--host-network) server_host_network=true; shift ;;
--bridge-network) server_host_network=false; shift ;;
--allow-insecure-development-auth) allow_insecure=true; shift ;;
--output)
[ "$#" -ge 2 ] || die "$1 requires a value"
output_path=$2
shift 2
;;
--help|-h) usage; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$advertise_ip" ] || die "--advertise-ip is required"
[ -f "$template_path" ] || die "Docker environment template not found: $template_path"
[ ! -e "$output_path" ] || die "$output_path already exists; initialization never overwrites live credentials"
command -v openssl >/dev/null 2>&1 || die "openssl is required"
is_ipv6=false
case "$advertise_ip" in
*:*) validate_ipv6 "$advertise_ip" || die "--advertise-ip is not a valid IPv6 address"; is_ipv6=true ;;
*) validate_ipv4 "$advertise_ip" || die "--advertise-ip is not a valid IPv4 address" ;;
esac
case "$admin_bind_ip" in
*:*) validate_ipv6 "$admin_bind_ip" || die "--admin-bind-ip is not a valid IPv6 address" ;;
*) validate_ipv4 "$admin_bind_ip" || die "--admin-bind-ip is not a valid IPv4 address" ;;
esac
is_loopback=false
if [ "$is_ipv6" = true ]; then
case "$advertise_ip" in ::1|0:0:0:0:0:0:0:1) is_loopback=true ;; esac
else
case "$advertise_ip" in 127.*) is_loopback=true ;; esac
fi
if [ -z "$public_base_url" ]; then
[ "$is_loopback" = true ] || die "--public-base-url is required when --advertise-ip is not loopback"
if [ "$is_ipv6" = true ]; then public_base_url='http://[::1]:2401'; else public_base_url='http://127.0.0.1:2401'; fi
fi
[ -n "$public_web_base_url" ] || public_web_base_url=$public_base_url
validate_http_url "--public-base-url" "$public_base_url"
validate_http_url "--public-web-base-url" "$public_web_base_url"
if [ "$is_loopback" = false ] && [ "$allow_insecure" = false ]; then
die "Internet/LAN development-code auth requires --allow-insecure-development-auth; for production generate on loopback, then configure the webhook provider before startup"
fi
public_bind_ip=0.0.0.0
local_bind_ip=127.0.0.1
if [ "$is_ipv6" = true ]; then public_bind_ip=::; local_bind_ip=::1; fi
if [ "$is_loopback" = true ]; then
public_bind_ip=$advertise_ip
local_bind_ip=$advertise_ip
elif [ "${public_base_url%%:*}" = http ] && [ "$(url_host "$public_base_url")" = "$advertise_ip" ]; then
local_bind_ip=$advertise_ip
fi
public_listen_host=$public_bind_ip
local_listen_host=$local_bind_ip
server_health_url_host=$local_bind_ip
if [ "$is_ipv6" = true ]; then
public_listen_host="[$public_bind_ip]"
local_listen_host="[$local_bind_ip]"
server_health_url_host="[$local_bind_ip]"
fi
turn_enable=true
turn_advertise_ip=$advertise_ip
if [ "$is_ipv6" = true ]; then
turn_enable=false
turn_advertise_ip=127.0.0.1
fi
admin_health_ip=$admin_bind_ip
case "$admin_bind_ip" in 0.0.0.0) admin_health_ip=127.0.0.1 ;; ::) admin_health_ip=::1 ;; esac
admin_listen_host=$admin_bind_ip
case "$admin_bind_ip" in *:*) admin_listen_host="[$admin_bind_ip]" ;; esac
rtmp_host=$advertise_ip
[ "$is_ipv6" = true ] && rtmp_host="[$rtmp_host]"
build_commit=$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || printf unknown)
build_branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || printf unknown)
build_tree_state=unknown
if git -C "$repo_root" status --porcelain >/dev/null 2>&1; then
if [ -n "$(git -C "$repo_root" status --porcelain)" ]; then build_tree_state=dirty; else build_tree_state=clean; fi
fi
POSTGRES_PASSWORD=$(random_hex 24)
TELESRV_BUILD_COMMIT=$build_commit
TELESRV_BUILD_BRANCH=$build_branch
TELESRV_BUILD_TREE_STATE=$build_tree_state
TELESRV_BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
TELESRV_POSTGRES_DSN="postgres://telesrv:$POSTGRES_PASSWORD@127.0.0.1:15432/telesrv_main?sslmode=disable"
TELESRV_REDIS_PASSWORD=$(random_hex 32)
TELESRV_ADMIN_API_TOKEN=$(random_hex 32)
TELESRV_ADMIN_UI_PASSWORD=$(random_hex 24)
TELESRV_ADMIN_SESSION_KEY=$(random_hex 32)
TELESRV_TURN_SECRET=$(random_hex 32)
TELESRV_OTP_WEBHOOK_SECRET=$(random_hex 32)
TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH=$allow_insecure
[ "$is_loopback" = true ] && TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH=true
TELESRV_ADVERTISE_IP=$advertise_ip
TELESRV_PUBLIC_BASE_URL=$public_base_url
TELESRV_PUBLIC_WEB_BASE_URL=$public_web_base_url
TELESRV_SERVER_HOST_NETWORK=$server_host_network
TELESRV_SFU_ADVERTISE_IP=$advertise_ip
TELESRV_TURN_ENABLE=$turn_enable
TELESRV_TURN_ADVERTISE_IP=$turn_advertise_ip
TELESRV_LIVESTREAM_RTMP_URL="rtmp://$rtmp_host:2400/live"
TELESRV_PUBLIC_BIND_IP=$public_bind_ip
TELESRV_PUBLIC_LISTEN_HOST=$public_listen_host
TELESRV_LOCAL_BIND_IP=$local_bind_ip
TELESRV_LOCAL_LISTEN_HOST=$local_listen_host
TELESRV_SERVER_HEALTH_IP=$local_bind_ip
TELESRV_SERVER_HEALTH_URL_HOST=$server_health_url_host
TELESRV_ADMIN_BIND_IP=$admin_bind_ip
TELESRV_ADMIN_LISTEN_HOST=$admin_listen_host
TELESRV_ADMIN_HEALTH_IP=$admin_health_ip
replacement_keys='TELESRV_BUILD_COMMIT TELESRV_BUILD_BRANCH TELESRV_BUILD_TREE_STATE TELESRV_BUILD_DATE POSTGRES_PASSWORD TELESRV_POSTGRES_DSN TELESRV_REDIS_PASSWORD TELESRV_ADMIN_API_TOKEN TELESRV_ADMIN_UI_PASSWORD TELESRV_ADMIN_SESSION_KEY TELESRV_TURN_SECRET TELESRV_OTP_WEBHOOK_SECRET TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH TELESRV_ADVERTISE_IP TELESRV_PUBLIC_BASE_URL TELESRV_PUBLIC_WEB_BASE_URL TELESRV_SERVER_HOST_NETWORK TELESRV_SFU_ADVERTISE_IP TELESRV_TURN_ENABLE TELESRV_TURN_ADVERTISE_IP TELESRV_LIVESTREAM_RTMP_URL TELESRV_PUBLIC_BIND_IP TELESRV_PUBLIC_LISTEN_HOST TELESRV_LOCAL_BIND_IP TELESRV_LOCAL_LISTEN_HOST TELESRV_SERVER_HEALTH_IP TELESRV_SERVER_HEALTH_URL_HOST TELESRV_ADMIN_BIND_IP TELESRV_ADMIN_LISTEN_HOST TELESRV_ADMIN_HEALTH_IP'
export POSTGRES_PASSWORD TELESRV_BUILD_COMMIT TELESRV_BUILD_BRANCH TELESRV_BUILD_TREE_STATE TELESRV_BUILD_DATE
export TELESRV_POSTGRES_DSN TELESRV_REDIS_PASSWORD TELESRV_ADMIN_API_TOKEN TELESRV_ADMIN_UI_PASSWORD
export TELESRV_ADMIN_SESSION_KEY TELESRV_TURN_SECRET TELESRV_OTP_WEBHOOK_SECRET TELESRV_ALLOW_INSECURE_DEVELOPMENT_AUTH
export TELESRV_ADVERTISE_IP TELESRV_PUBLIC_BASE_URL TELESRV_PUBLIC_WEB_BASE_URL TELESRV_SERVER_HOST_NETWORK
export TELESRV_SFU_ADVERTISE_IP TELESRV_TURN_ENABLE TELESRV_TURN_ADVERTISE_IP TELESRV_LIVESTREAM_RTMP_URL
export TELESRV_PUBLIC_BIND_IP TELESRV_PUBLIC_LISTEN_HOST TELESRV_LOCAL_BIND_IP TELESRV_LOCAL_LISTEN_HOST
export TELESRV_SERVER_HEALTH_IP TELESRV_SERVER_HEALTH_URL_HOST TELESRV_ADMIN_BIND_IP TELESRV_ADMIN_LISTEN_HOST TELESRV_ADMIN_HEALTH_IP
output_dir=$(dirname -- "$output_path")
[ -d "$output_dir" ] || die "output directory does not exist: $output_dir"
temporary_path=$(mktemp "$output_path.tmp.XXXXXX")
cleanup() { [ ! -e "$temporary_path" ] || unlink "$temporary_path"; }
trap cleanup EXIT HUP INT TERM
awk -v keys="$replacement_keys" '
BEGIN { count = split(keys, list, " "); for (i = 1; i <= count; i++) wanted[list[i]] = 1 }
{
separator = index($0, "=")
key = separator ? substr($0, 1, separator - 1) : ""
if (key in wanted) { print key "=" ENVIRON[key]; seen[key] = 1 } else print
}
END {
for (key in wanted) if (!(key in seen)) { print "template is missing " key > "/dev/stderr"; missing = 1 }
exit missing
}
' "$template_path" >"$temporary_path"
chmod 0600 "$temporary_path"
mv "$temporary_path" "$output_path"
trap - EXIT HUP INT TERM
printf 'Created %s with owner-only permissions.\n' "$output_path"

View file

@ -12,6 +12,9 @@ hidden, and verifies that the port is listening again.
param(
[string]$Listen = "0.0.0.0:2398",
[string]$AdvertiseIP,
[string]$PostgresDSN,
[string]$PostgresContainer = "telesrv-postgres",
[string]$PostgresUser = "telesrv",
[string]$ExePath,
[string]$LogDir,
[int]$HealthTimeoutSeconds = 20,
@ -221,10 +224,30 @@ New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
Push-Location $RepoRoot
try {
$branch = Get-GitOutput @("branch", "--show-current")
$postgresDatabase = "external"
if ([string]::IsNullOrWhiteSpace($PostgresDSN)) {
switch ($branch) {
"main" { $postgresDatabase = "telesrv_main" }
"v2" { $postgresDatabase = "telesrv_v2" }
default {
throw "Branch '$branch' has no implicit local PostgreSQL database. Pass -PostgresDSN explicitly."
}
}
Write-Step "Resolve branch-isolated PostgreSQL"
& (Join-Path $PSScriptRoot "ensure-local-databases.ps1") `
-PostgresContainer $PostgresContainer `
-DbUser $PostgresUser
$PostgresDSN = "postgres://telesrv:telesrv@127.0.0.1:5432/$postgresDatabase`?sslmode=disable"
Write-Host "[ok] branch=$branch database=$postgresDatabase"
} else {
Write-Step "Use explicit PostgreSQL DSN"
Write-Host "[ok] branch=$branch database=explicit-override"
}
if (-not $SkipBuild) {
Write-Step "Build telesrv"
$commit = Get-GitOutput @("rev-parse", "HEAD")
$branch = Get-GitOutput @("branch", "--show-current")
$dirty = Get-GitOutput @("status", "--porcelain", "--untracked-files=no") -Default ""
$treeState = "clean"
if ($dirty.Length -gt 0) {
@ -293,6 +316,7 @@ try {
$stderrPath = Join-Path $LogDir "telesrv-$stamp.err.log"
$env:TELESRV_LISTEN = $Listen
$env:TELESRV_POSTGRES_DSN = $PostgresDSN
if ($AdvertiseIP) {
$env:TELESRV_ADVERTISE_IP = $AdvertiseIP
}
@ -338,6 +362,7 @@ try {
Pid = $proc.Id
Listen = $Listen
AdvertiseIP = $env:TELESRV_ADVERTISE_IP
PostgresDatabase = $postgresDatabase
Exe = $ExePath
Stdout = $stdoutPath
Stderr = $stderrPath

90
scripts/start-docker.ps1 Normal file
View file

@ -0,0 +1,90 @@
[CmdletBinding()]
param(
[Parameter()]
[string]$AdvertiseIP = "",
[Parameter()]
[string]$PublicBaseURL = "",
[Parameter()]
[string]$PublicWebBaseURL = "",
[Parameter()]
[string]$AdminBindIP = "",
[Parameter()]
[switch]$HostNetwork,
[Parameter()]
[switch]$BridgeNetwork,
[Parameter()]
[switch]$AllowInsecureDevelopmentAuth,
[Parameter()]
[switch]$Build
)
$ErrorActionPreference = "Stop"
if ($HostNetwork -and $BridgeNetwork) { throw "HostNetwork and BridgeNetwork are mutually exclusive." }
$repoRoot = Split-Path -Parent $PSScriptRoot
$dockerDir = Join-Path $repoRoot "deploy\docker"
$composePath = Join-Path $dockerDir "compose.yaml"
$envPath = Join-Path $dockerDir ".env"
$generatorPath = Join-Path $PSScriptRoot "new-docker-env.ps1"
if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) {
if ([string]::IsNullOrWhiteSpace($AdvertiseIP)) { $AdvertiseIP = "127.0.0.1" }
$generatorArguments = @{ AdvertiseIP = $AdvertiseIP }
if (-not [string]::IsNullOrWhiteSpace($PublicBaseURL)) { $generatorArguments.PublicBaseURL = $PublicBaseURL }
if (-not [string]::IsNullOrWhiteSpace($PublicWebBaseURL)) { $generatorArguments.PublicWebBaseURL = $PublicWebBaseURL }
if (-not [string]::IsNullOrWhiteSpace($AdminBindIP)) { $generatorArguments.AdminBindIP = $AdminBindIP }
if ($HostNetwork) { $generatorArguments.HostNetwork = $true }
if ($BridgeNetwork) { $generatorArguments.BridgeNetwork = $true }
if ($AllowInsecureDevelopmentAuth) { $generatorArguments.AllowInsecureDevelopmentAuth = $true }
& $generatorPath @generatorArguments
}
elseif ($PSBoundParameters.Keys | Where-Object { $_ -notin @("Build") }) {
Write-Warning "deploy/docker/.env already exists; initialization options were ignored to preserve credentials and deployment identity."
}
$deployment = @{}
foreach ($line in Get-Content -LiteralPath $envPath) {
if ($line -match '^([A-Z0-9_]+)=(.*)$') { $deployment[$Matches[1]] = $Matches[2] }
}
if ($deployment['TELESRV_DEPLOYMENT_PROFILE'] -ne 'main-monolith-v1') {
throw "$envPath belongs to an older or different topology; move it aside and rerun so credentials are regenerated safely."
}
$composeBase = @("compose", "--project-directory", $dockerDir, "--env-file", $envPath, "--file", $composePath)
$hostNetworkValue = $deployment['TELESRV_SERVER_HOST_NETWORK']
if ([string]::IsNullOrWhiteSpace($hostNetworkValue)) { $hostNetworkValue = "true" }
if ($hostNetworkValue -ne "true" -and $hostNetworkValue -ne "false") { throw "TELESRV_SERVER_HOST_NETWORK must be true or false." }
if ($hostNetworkValue -eq "false") { $composeBase += @("--file", (Join-Path $dockerDir "compose.bridge-network.yaml")) }
function Invoke-Compose {
param([string[]]$Arguments)
& docker @composeBase @Arguments
if ($LASTEXITCODE -ne 0) { throw "docker compose $($Arguments -join ' ') failed with exit code $LASTEXITCODE" }
}
Invoke-Compose -Arguments @("config", "--quiet")
if ($Build) { Invoke-Compose -Arguments @("build", "--pull") } else { Invoke-Compose -Arguments @("pull") }
try { Invoke-Compose -Arguments @("up", "--detach", "--no-build", "--wait", "--wait-timeout", "600") }
catch { & docker @composeBase logs --no-color --tail 160; throw }
Invoke-Compose -Arguments @("ps", "--all")
Write-Host "gramsrv main Docker stack is ready. Configuration: $envPath"
if ($deployment['TELESRV_PHONE_CODE_DELIVERY_PROVIDER'] -eq 'development') { Write-Host "Development login code: $($deployment['TELESRV_DEV_AUTH_CODE'])" }
Write-Host "MTProto: $($deployment['TELESRV_ADVERTISE_IP']):$($deployment['TELESRV_SERVER_PORT'])"
if ($deployment['TELESRV_TURN_ENABLE'] -eq 'true') {
Write-Host "TURN/STUN: udp://$($deployment['TELESRV_TURN_ADVERTISE_IP']):$($deployment['TELESRV_TURN_UDP_PORT'])"
$turnRelayMaxPort = $deployment['TELESRV_TURN_RELAY_MAX_PORT']
if ($hostNetworkValue -eq 'false') { $turnRelayMaxPort = $deployment['TELESRV_TURN_BRIDGE_RELAY_MAX_PORT'] }
Write-Host "TURN relay UDP range: $($deployment['TELESRV_TURN_RELAY_MIN_PORT'])-$turnRelayMaxPort"
}
$adminHost = $deployment['TELESRV_ADMIN_BIND_IP']
if ($adminHost -eq "0.0.0.0" -or $adminHost -eq "::") { $adminHost = $deployment['TELESRV_ADVERTISE_IP'] }
if ($adminHost.Contains(":")) { $adminHost = "[${adminHost}]" }
Write-Host "Admin UI: http://${adminHost}:$($deployment['TELESRV_ADMIN_PORT']) (password is stored in $envPath)"

103
scripts/start-docker.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ./scripts/start-docker.sh [options]
Initialization options (used only when deploy/docker/.env is absent):
--advertise-ip IP
--public-base-url URL
--public-web-base-url URL
--admin-bind-ip IP
--host-network Direct host networking for the monolith (default).
--bridge-network Docker port publishing compatibility mode.
--allow-insecure-development-auth
Other options:
--build Build local images instead of pulling published images.
--help
EOF
}
script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
docker_dir="$repo_root/deploy/docker"
compose_path="$docker_dir/compose.yaml"
env_path="$docker_dir/.env"
generator_path="$script_dir/new-docker-env.sh"
advertise_ip=
public_base_url=
public_web_base_url=
admin_bind_ip=
network_mode=
allow_insecure=false
build=false
initialization_options=false
while [[ $# -gt 0 ]]; do
case "$1" in
--advertise-ip) [[ $# -ge 2 ]] || { printf '%s requires a value\n' "$1" >&2; exit 1; }; advertise_ip=$2; initialization_options=true; shift 2 ;;
--public-base-url) [[ $# -ge 2 ]] || { printf '%s requires a value\n' "$1" >&2; exit 1; }; public_base_url=$2; initialization_options=true; shift 2 ;;
--public-web-base-url) [[ $# -ge 2 ]] || { printf '%s requires a value\n' "$1" >&2; exit 1; }; public_web_base_url=$2; initialization_options=true; shift 2 ;;
--admin-bind-ip) [[ $# -ge 2 ]] || { printf '%s requires a value\n' "$1" >&2; exit 1; }; admin_bind_ip=$2; initialization_options=true; shift 2 ;;
--host-network) network_mode=host; initialization_options=true; shift ;;
--bridge-network) network_mode=bridge; initialization_options=true; shift ;;
--allow-insecure-development-auth) allow_insecure=true; initialization_options=true; shift ;;
--build) build=true; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'start-docker: unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac
done
if [[ ! -f "$env_path" ]]; then
[[ -n "$advertise_ip" ]] || advertise_ip=127.0.0.1
generator_args=(--advertise-ip "$advertise_ip")
[[ -z "$public_base_url" ]] || generator_args+=(--public-base-url "$public_base_url")
[[ -z "$public_web_base_url" ]] || generator_args+=(--public-web-base-url "$public_web_base_url")
[[ -z "$admin_bind_ip" ]] || generator_args+=(--admin-bind-ip "$admin_bind_ip")
[[ "$network_mode" != host ]] || generator_args+=(--host-network)
[[ "$network_mode" != bridge ]] || generator_args+=(--bridge-network)
[[ "$allow_insecure" = false ]] || generator_args+=(--allow-insecure-development-auth)
"$generator_path" "${generator_args[@]}"
elif [[ "$initialization_options" = true ]]; then
printf 'start-docker: deploy/docker/.env already exists; initialization options were ignored to preserve credentials and deployment identity.\n' >&2
fi
env_value() { awk -F= -v key="$1" '$1 == key { print substr($0, length(key) + 2); exit }' "$env_path"; }
if [[ "$(env_value TELESRV_DEPLOYMENT_PROFILE)" != main-monolith-v1 ]]; then
printf 'start-docker: %s belongs to an older or different topology; move it aside and rerun so credentials are regenerated safely.\n' "$env_path" >&2
exit 1
fi
configured_host_network=$(env_value TELESRV_SERVER_HOST_NETWORK)
[[ -n "$configured_host_network" ]] || configured_host_network=true
case "$configured_host_network" in true|false) ;; *) printf 'start-docker: TELESRV_SERVER_HOST_NETWORK must be true or false\n' >&2; exit 1 ;; esac
compose=(docker compose --project-directory "$docker_dir" --env-file "$env_path" --file "$compose_path")
if [[ "$configured_host_network" = false ]]; then compose+=(--file "$docker_dir/compose.bridge-network.yaml"); fi
"${compose[@]}" version >/dev/null
"${compose[@]}" config --quiet
if [[ "$build" = true ]]; then "${compose[@]}" build --pull; else "${compose[@]}" pull; fi
if ! "${compose[@]}" up --detach --no-build --wait --wait-timeout 600; then
"${compose[@]}" logs --no-color --tail 160 || true
exit 1
fi
"${compose[@]}" ps --all
printf 'gramsrv main Docker stack is ready. Configuration: %s\n' "$env_path"
if [[ "$(env_value TELESRV_PHONE_CODE_DELIVERY_PROVIDER)" = development ]]; then printf 'Development login code: %s\n' "$(env_value TELESRV_DEV_AUTH_CODE)"; fi
printf 'MTProto: %s:%s\n' "$(env_value TELESRV_ADVERTISE_IP)" "$(env_value TELESRV_SERVER_PORT)"
if [[ "$(env_value TELESRV_TURN_ENABLE)" = true ]]; then
printf 'TURN/STUN: udp://%s:%s\n' "$(env_value TELESRV_TURN_ADVERTISE_IP)" "$(env_value TELESRV_TURN_UDP_PORT)"
turn_relay_max_port=$(env_value TELESRV_TURN_RELAY_MAX_PORT)
if [[ "$configured_host_network" = false ]]; then turn_relay_max_port=$(env_value TELESRV_TURN_BRIDGE_RELAY_MAX_PORT); fi
printf 'TURN relay UDP range: %s-%s\n' "$(env_value TELESRV_TURN_RELAY_MIN_PORT)" "$turn_relay_max_port"
fi
admin_host=$(env_value TELESRV_ADMIN_BIND_IP)
if [[ "$admin_host" = 0.0.0.0 || "$admin_host" = :: ]]; then admin_host=$(env_value TELESRV_ADVERTISE_IP); fi
[[ "$admin_host" != *:* ]] || admin_host="[$admin_host]"
printf 'Admin UI: http://%s:%s (password is stored in %s)\n' "$admin_host" "$(env_value TELESRV_ADMIN_PORT)" "$env_path"