refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,433 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package configgen
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/secrets"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/state"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
func GenerateLiveKitYAML(st *state.BootstrapState, sec *secrets.Secrets, redisAddr string) string {
var webhookBlock string
if len(st.Webhooks) > 0 {
var urls []string
for _, u := range st.Webhooks {
urls = append(urls, fmt.Sprintf(" - '%s'", u))
}
webhookBlock = fmt.Sprintf(`webhook:
api_key: '%s'
urls:
%s
`, sec.LiveKitAPIKey, strings.Join(urls, "\n"))
}
return fmt.Sprintf(`port: %d
bind_addresses:
- "127.0.0.1"
log_level: info
rtc:
tcp_port: %d
port_range_start: %d
port_range_end: %d
use_external_ip: true
turn_servers:
- host: "%s"
port: 443
protocol: tls
username: "%s"
credential: "%s"
- host: "%s"
port: %d
protocol: udp
username: "%s"
credential: "%s"
redis:
address: "%s"
username: ""
password: "%s"
db: 0
use_tls: false
keys:
"%s": "%s"
%s`,
st.Ports.LiveKitHTTPLocal,
st.Ports.LiveKitRTCTCP,
st.Ports.LiveKitRTCUDPStart,
st.Ports.LiveKitRTCUDPEnd,
st.Domains.TURN,
sec.TURNUsername,
sec.TURNPassword,
st.Domains.TURN,
st.Ports.TURNListenPort,
sec.TURNUsername,
sec.TURNPassword,
redisAddr,
sec.KVPassword,
sec.LiveKitAPIKey,
sec.LiveKitAPISecret,
strings.TrimSpace(webhookBlock),
)
}
func GenerateKVConf(sec *secrets.Secrets, bindHost string, port int, dataDir string) string {
return fmt.Sprintf(`bind %s
protected-mode yes
port %d
tcp-backlog 511
timeout 0
tcp-keepalive 300
daemonize no
supervised no
dir %s
dbfilename dump.rdb
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
requirepass %s
`, bindHost, port, dataDir, sec.KVPassword)
}
func GenerateCoTURNConf(st *state.BootstrapState, sec *secrets.Secrets, publicIPv4, privateIPv4 string) string {
external := publicIPv4
if privateIPv4 != "" && privateIPv4 != publicIPv4 {
external = fmt.Sprintf("%s/%s", publicIPv4, privateIPv4)
}
return fmt.Sprintf(`listening-port=%d
fingerprint
lt-cred-mech
user=%s:%s
realm=%s
server-name=%s
no-multicast-peers
no-loopback-peers
stale-nonce
no-tls
no-dtls
min-port=%d
max-port=%d
external-ip=%s
`, st.Ports.TURNListenPort,
sec.TURNUsername, sec.TURNPassword,
st.Domains.TURN,
st.Domains.TURN,
st.Ports.TURNRelayUDPStart,
st.Ports.TURNRelayUDPEnd,
external)
}
func GenerateLiveKitUnit(st *state.BootstrapState) string {
return fmt.Sprintf(`[Unit]
Description=LiveKit Server
After=network-online.target
Wants=network-online.target
[Service]
User=livekit
Group=livekit
ExecStart=%s/livekit-server --config %s/livekit.yaml
Restart=on-failure
RestartSec=2
LimitNOFILE=1048576
WorkingDirectory=%s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=%s %s %s /var/lib/livekit
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true
RestrictRealtime=true
[Install]
WantedBy=multi-user.target
`, st.Paths.LiveKitBinDir, st.Paths.ConfigDir, st.Paths.LiveKitInstallDir,
st.Paths.LiveKitLogDir, st.Paths.LiveKitInstallDir, st.Paths.ConfigDir)
}
func GenerateCaddyJSON(st *state.BootstrapState) string {
caddyConfig := map[string]interface{}{
"storage": map[string]interface{}{
"module": "file_system",
"root": st.Paths.CaddyStorageDir,
},
"logging": map[string]interface{}{
"logs": map[string]interface{}{
"default": map[string]interface{}{
"level": "INFO",
},
},
},
"apps": map[string]interface{}{
"tls": map[string]interface{}{
"automation": map[string]interface{}{
"policies": []interface{}{
map[string]interface{}{
"subjects": []string{st.Domains.LiveKit, st.Domains.TURN},
"issuers": []interface{}{
map[string]interface{}{
"module": "acme",
"email": st.ACMEEmail,
},
},
},
},
},
"certificates": map[string]interface{}{
"automate": []string{st.Domains.LiveKit, st.Domains.TURN},
},
},
"layer4": map[string]interface{}{
"servers": map[string]interface{}{
"main443": map[string]interface{}{
"listen": []string{":443"},
"routes": []interface{}{
map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"tls": map[string]interface{}{
"sni": []string{st.Domains.TURN},
},
},
},
"handle": []interface{}{
map[string]interface{}{
"handler": "tls",
"connection_policies": []interface{}{
map[string]interface{}{
"alpn": []string{"acme-tls/1", "h2", "http/1.1"},
},
},
},
map[string]interface{}{
"handler": "proxy",
"upstreams": []interface{}{
map[string]interface{}{
"dial": []string{fmt.Sprintf("127.0.0.1:%d", st.Ports.TURNListenPort)},
},
},
},
},
},
map[string]interface{}{
"match": []interface{}{
map[string]interface{}{
"tls": map[string]interface{}{
"sni": []string{st.Domains.LiveKit},
},
},
},
"handle": []interface{}{
map[string]interface{}{
"handler": "tls",
"connection_policies": []interface{}{
map[string]interface{}{
"alpn": []string{"acme-tls/1", "http/1.1"},
},
},
},
map[string]interface{}{
"handler": "proxy",
"upstreams": []interface{}{
map[string]interface{}{
"dial": []string{fmt.Sprintf("127.0.0.1:%d", st.Ports.LiveKitHTTPLocal)},
},
},
},
},
},
},
},
},
},
},
}
data, err := json.MarshalIndent(caddyConfig, "", " ")
if err != nil {
panic("failed to marshal caddy config: " + err.Error())
}
return string(data) + "\n"
}
func GenerateCaddyUnit(st *state.BootstrapState) string {
return fmt.Sprintf(`[Unit]
Description=Caddy (custom build with caddy-l4) for LiveKit + TURN/TLS
After=network-online.target
Wants=network-online.target
[Service]
User=caddy
Group=caddy
ExecStart=%s run --config %s/caddy.json
ExecReload=%s reload --config %s/caddy.json
Restart=on-failure
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
WorkingDirectory=%s
[Install]
WantedBy=multi-user.target
`, st.Paths.CaddyBin, st.Paths.ConfigDir, st.Paths.CaddyBin, st.Paths.ConfigDir, st.Paths.CaddyStorageDir)
}
func GenerateCoTURNUnit(st *state.BootstrapState) string {
return fmt.Sprintf(`[Unit]
Description=CoTURN for LiveKit
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/turnserver -c %s/coturn.conf -n
Restart=on-failure
RestartSec=2
LimitNOFILE=1048576
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
`, st.Paths.ConfigDir)
}
func GenerateKVUnit(st *state.BootstrapState, kvBin string) string {
return fmt.Sprintf(`[Unit]
Description=Redis-compatible KV store for LiveKit (managed by livekitctl)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=%s %s/kv.conf
Restart=on-failure
RestartSec=2
LimitNOFILE=1048576
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
`, kvBin, st.Paths.ConfigDir)
}
type WriteAllConfigsParams struct {
State *state.BootstrapState
Secrets *secrets.Secrets
PublicIPv4 string
PrivateIPv4 string
KVBin string
}
func WriteAllConfigs(params WriteAllConfigsParams) error {
st := params.State
sec := params.Secrets
cfgDir := st.Paths.ConfigDir
if err := util.EnsureDir(cfgDir, 0755, -1, -1); err != nil {
return err
}
ugLiveKit := util.LookupUserGroup("livekit")
ugCaddy := util.LookupUserGroup("caddy")
lkUID, lkGID := -1, -1
if ugLiveKit != nil {
lkUID, lkGID = ugLiveKit.UID, ugLiveKit.GID
}
caddyUID, caddyGID := -1, -1
if ugCaddy != nil {
caddyUID, caddyGID = ugCaddy.UID, ugCaddy.GID
}
if err := util.EnsureDir(st.Paths.LiveKitLogDir, 0755, lkUID, lkGID); err != nil {
return err
}
if err := util.EnsureDir(st.Paths.CaddyStorageDir, 0700, caddyUID, caddyGID); err != nil {
return err
}
if err := util.EnsureDir(st.Paths.CaddyLogDir, 0755, caddyUID, caddyGID); err != nil {
return err
}
if err := util.EnsureDir(st.Paths.KVDataDir, 0700, -1, -1); err != nil {
return err
}
redisAddr := fmt.Sprintf("%s:%d", st.KV.BindHost, st.KV.Port)
livekitYAML := GenerateLiveKitYAML(st, sec, redisAddr)
if err := util.AtomicWriteText(filepath.Join(cfgDir, "livekit.yaml"), livekitYAML, 0640, lkUID, lkGID); err != nil {
return err
}
kvConf := GenerateKVConf(sec, st.KV.BindHost, st.KV.Port, st.Paths.KVDataDir)
if err := util.AtomicWriteText(filepath.Join(cfgDir, "kv.conf"), kvConf, 0600, -1, -1); err != nil {
return err
}
coturnConf := GenerateCoTURNConf(st, sec, params.PublicIPv4, params.PrivateIPv4)
if err := util.AtomicWriteText(filepath.Join(cfgDir, "coturn.conf"), coturnConf, 0600, -1, -1); err != nil {
return err
}
caddyJSON := GenerateCaddyJSON(st)
if err := util.AtomicWriteText(filepath.Join(cfgDir, "caddy.json"), caddyJSON, 0644, -1, -1); err != nil {
return err
}
if util.FileExists(st.Paths.UnitDir) {
if err := util.AtomicWriteText(filepath.Join(st.Paths.UnitDir, "livekit.service"), GenerateLiveKitUnit(st), 0644, -1, -1); err != nil {
return err
}
if err := util.AtomicWriteText(filepath.Join(st.Paths.UnitDir, "caddy.service"), GenerateCaddyUnit(st), 0644, -1, -1); err != nil {
return err
}
if err := util.AtomicWriteText(filepath.Join(st.Paths.UnitDir, "livekit-coturn.service"), GenerateCoTURNUnit(st), 0644, -1, -1); err != nil {
return err
}
if err := util.AtomicWriteText(filepath.Join(st.Paths.UnitDir, "livekit-kv.service"), GenerateKVUnit(st, params.KVBin), 0644, -1, -1); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package constants
const (
DefaultLiveKitVersion = "v1.9.11"
DefaultXcaddyVersion = "v0.4.5"
DefaultCaddyVersion = "v2.10.2"
DefaultCaddyL4Version = "master"
)
type Ports struct {
LiveKitHTTPLocal int `json:"livekit_http_local"`
LiveKitRTCTCP int `json:"livekit_rtc_tcp"`
LiveKitRTCUDPStart int `json:"livekit_rtc_udp_start"`
LiveKitRTCUDPEnd int `json:"livekit_rtc_udp_end"`
TURNListenPort int `json:"turn_listen_port"`
TURNRelayUDPStart int `json:"turn_relay_udp_start"`
TURNRelayUDPEnd int `json:"turn_relay_udp_end"`
KVBindHost string `json:"kv_bind_host"`
KVPort int `json:"kv_port"`
}
func DefaultPorts() Ports {
return Ports{
LiveKitHTTPLocal: 7880,
LiveKitRTCTCP: 7881,
LiveKitRTCUDPStart: 50000,
LiveKitRTCUDPEnd: 60000,
TURNListenPort: 3478,
TURNRelayUDPStart: 40000,
TURNRelayUDPEnd: 49999,
KVBindHost: "127.0.0.1",
KVPort: 6379,
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package dnswait
import (
"net"
"time"
)
func ResolveA(host string) []string {
var out []string
addrs, err := net.LookupIP(host)
if err != nil {
return out
}
for _, addr := range addrs {
if ip4 := addr.To4(); ip4 != nil {
out = append(out, ip4.String())
}
}
return out
}
func ResolveAAAA(host string) []string {
var out []string
addrs, err := net.LookupIP(host)
if err != nil {
return out
}
for _, addr := range addrs {
if addr.To4() == nil {
out = append(out, addr.String())
}
}
return out
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func WaitForDNS(livekitDomain, turnDomain, publicIPv4, publicIPv6 string, timeoutS, intervalS int) bool {
if timeoutS < 1 {
timeoutS = 1
}
if intervalS < 1 {
intervalS = 1
}
deadline := time.Now().Add(time.Duration(timeoutS) * time.Second)
for {
a1 := ResolveA(livekitDomain)
a2 := ResolveA(turnDomain)
ok4 := contains(a1, publicIPv4) && contains(a2, publicIPv4)
ok6 := true
if publicIPv6 != "" {
aaaa1 := ResolveAAAA(livekitDomain)
aaaa2 := ResolveAAAA(turnDomain)
ok6 = contains(aaaa1, publicIPv6) && contains(aaaa2, publicIPv6)
}
if ok4 && ok6 {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(time.Duration(intervalS) * time.Second)
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package download
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
)
type DownloadResult struct {
Path string
SHA256Verified bool
}
func httpGet(url string, timeoutS int) ([]byte, error) {
client := &http.Client{Timeout: time.Duration(timeoutS) * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "livekitctl/0.1")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func httpHeadOK(url string, timeoutS int) bool {
client := &http.Client{Timeout: time.Duration(timeoutS) * time.Second}
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", "livekitctl/0.1")
resp, err := client.Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 400
}
func parseSHA256File(text string) string {
t := strings.TrimSpace(text)
if t == "" {
return ""
}
parts := strings.Fields(t)
if len(parts) == 0 {
return ""
}
h := strings.ToLower(strings.TrimSpace(parts[0]))
if len(h) != 64 {
return ""
}
for _, c := range h {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return ""
}
}
return h
}
func DownloadWithOptionalSHA256(url, dest string, timeoutS, retries int) (*DownloadResult, error) {
if timeoutS <= 0 {
timeoutS = 30
}
if retries < 0 {
retries = 0
}
var lastErr error
for i := 0; i <= retries; i++ {
data, err := httpGet(url, timeoutS)
if err != nil {
lastErr = err
continue
}
dir := filepath.Dir(dest)
if err := os.MkdirAll(dir, 0755); err != nil {
lastErr = err
continue
}
if err := os.WriteFile(dest, data, 0644); err != nil {
lastErr = err
continue
}
shaURL := url + ".sha256"
verified := false
if httpHeadOK(shaURL, timeoutS) {
shaText, err := httpGet(shaURL, timeoutS)
if err == nil {
expected := parseSHA256File(string(shaText))
if expected != "" {
h := sha256.Sum256(data)
got := hex.EncodeToString(h[:])
if got != expected {
lastErr = errors.NewCmdError(fmt.Sprintf("SHA256 mismatch for %s", url), nil)
os.Remove(dest)
continue
}
verified = true
}
}
}
return &DownloadResult{Path: dest, SHA256Verified: verified}, nil
}
return nil, errors.NewCmdError(fmt.Sprintf("Download failed: %s (%v)", url, lastErr), nil)
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package errors
import "fmt"
type LiveKitCtlError struct {
Message string
Err error
}
func (e *LiveKitCtlError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *LiveKitCtlError) Unwrap() error {
return e.Err
}
type CmdError struct {
LiveKitCtlError
}
func NewCmdError(msg string, err error) *CmdError {
return &CmdError{LiveKitCtlError{Message: msg, Err: err}}
}
type ValidationError struct {
LiveKitCtlError
}
func NewValidationError(msg string) *ValidationError {
return &ValidationError{LiveKitCtlError{Message: msg}}
}
type PlatformError struct {
LiveKitCtlError
}
func NewPlatformError(msg string) *PlatformError {
return &PlatformError{LiveKitCtlError{Message: msg}}
}
func NewPlatformErrorf(format string, args ...interface{}) *PlatformError {
return &PlatformError{LiveKitCtlError{Message: fmt.Sprintf(format, args...)}}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package firewall
import (
"fmt"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/constants"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
type FirewallTool struct {
Name string
}
func DetectFirewallTool() FirewallTool {
if util.Which("ufw") != "" {
return FirewallTool{Name: "ufw"}
}
if util.Which("firewall-cmd") != "" {
return FirewallTool{Name: "firewalld"}
}
return FirewallTool{Name: "none"}
}
func ConfigureFirewall(tool FirewallTool, ports constants.Ports, enable bool) string {
if tool.Name == "none" {
return "Firewall tool not detected. Skipping."
}
if tool.Name == "ufw" {
util.Run([]string{"ufw", "allow", "22/tcp"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", "80/tcp"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", "443/tcp"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", fmt.Sprintf("%d/tcp", ports.LiveKitRTCTCP)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", fmt.Sprintf("%d:%d/udp", ports.LiveKitRTCUDPStart, ports.LiveKitRTCUDPEnd)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", fmt.Sprintf("%d/udp", ports.TURNListenPort)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", fmt.Sprintf("%d/tcp", ports.TURNListenPort)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"ufw", "allow", fmt.Sprintf("%d:%d/udp", ports.TURNRelayUDPStart, ports.TURNRelayUDPEnd)}, util.RunOptions{Check: false, Capture: true})
if enable {
util.Run([]string{"ufw", "--force", "enable"}, util.RunOptions{Check: false, Capture: true})
}
result, _ := util.Run([]string{"ufw", "status", "verbose"}, util.RunOptions{Check: false, Capture: true})
if result != nil {
return result.Output
}
return ""
}
if tool.Name == "firewalld" {
util.Run([]string{"firewall-cmd", "--permanent", "--add-service=ssh"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-service=http"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-service=https"}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-port", fmt.Sprintf("%d/tcp", ports.LiveKitRTCTCP)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-port", fmt.Sprintf("%d-%d/udp", ports.LiveKitRTCUDPStart, ports.LiveKitRTCUDPEnd)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-port", fmt.Sprintf("%d/udp", ports.TURNListenPort)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-port", fmt.Sprintf("%d/tcp", ports.TURNListenPort)}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"firewall-cmd", "--permanent", "--add-port", fmt.Sprintf("%d-%d/udp", ports.TURNRelayUDPStart, ports.TURNRelayUDPEnd)}, util.RunOptions{Check: false, Capture: true})
if enable {
util.Run([]string{"firewall-cmd", "--reload"}, util.RunOptions{Check: false, Capture: true})
}
result, _ := util.Run([]string{"firewall-cmd", "--list-all"}, util.RunOptions{Check: false, Capture: true})
if result != nil {
return result.Output
}
return ""
}
return "Unsupported firewall tool."
}

View File

@@ -0,0 +1,404 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package install
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/constants"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/download"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/platform"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
func DetectArchLinuxRelease() (string, error) {
m := strings.ToLower(runtime.GOARCH)
switch m {
case "amd64":
return "amd64", nil
case "arm64":
return "arm64", nil
case "arm":
return "armv7", nil
}
return "", errors.NewPlatformErrorf("Unsupported architecture: %s", runtime.GOARCH)
}
func LiveKitReleaseURL(tag, arch string) string {
v := strings.TrimPrefix(tag, "v")
return fmt.Sprintf("https://github.com/livekit/livekit/releases/download/v%s/livekit_%s_linux_%s.tar.gz", v, v, arch)
}
func EnsureUsers() error {
if util.Which("useradd") == "" {
return nil
}
if err := ensureSystemUser("livekit", "/var/lib/livekit"); err != nil {
return err
}
return ensureSystemUser("caddy", "/var/lib/caddy")
}
func ensureSystemUser(name, home string) error {
output, exitCode := util.RunCaptureNoCheck([]string{"id", "-u", name})
if exitCode == 0 && strings.TrimSpace(output) != "" {
return nil
}
return util.RunSimple([]string{
"useradd",
"--system",
"--home", home,
"--shell", "/usr/sbin/nologin",
name,
})
}
func InstallBasePackages(pm *platform.PackageManager) error {
var pkgs []string
switch pm.Kind {
case "apt":
pkgs = []string{
"ca-certificates",
"curl",
"tar",
"xz-utils",
"dnsutils",
"iproute2",
"libcap2-bin",
"coturn",
"git",
"build-essential",
"golang-go",
}
case "dnf", "yum":
pkgs = []string{
"ca-certificates",
"curl",
"tar",
"xz",
"bind-utils",
"iproute",
"libcap",
"coturn",
"git",
"gcc",
"gcc-c++",
"make",
"golang",
}
case "pacman":
pkgs = []string{
"ca-certificates",
"curl",
"tar",
"xz",
"bind",
"iproute2",
"libcap",
"coturn",
"git",
"base-devel",
"go",
}
case "zypper":
pkgs = []string{
"ca-certificates",
"curl",
"tar",
"xz",
"bind-utils",
"iproute2",
"libcap-progs",
"coturn",
"git",
"gcc",
"gcc-c++",
"make",
"go",
}
case "apk":
pkgs = []string{
"ca-certificates",
"curl",
"tar",
"xz",
"bind-tools",
"iproute2",
"libcap",
"coturn",
"git",
"build-base",
"go",
}
}
return pm.Install(pkgs)
}
func InstallKVBinary(pm *platform.PackageManager) (string, error) {
if bin := util.Which("valkey-server"); bin != "" {
util.Logf("Using existing valkey-server: %s", bin)
return bin, nil
}
if bin := util.Which("redis-server"); bin != "" {
util.Logf("Using existing redis-server: %s", bin)
return bin, nil
}
util.Log("Installing KV store...")
switch pm.Kind {
case "apt":
if err := pm.Install([]string{"valkey-server"}); err != nil {
util.Log("valkey-server not available, trying redis-server...")
if err2 := pm.Install([]string{"redis-server"}); err2 != nil {
return "", err
}
}
case "dnf", "yum":
if err := pm.Install([]string{"valkey"}); err != nil {
util.Log("valkey not available, trying redis...")
if err2 := pm.Install([]string{"redis"}); err2 != nil {
return "", err
}
}
case "pacman":
if err := pm.Install([]string{"valkey"}); err != nil {
util.Log("valkey not available, trying redis...")
if err2 := pm.Install([]string{"redis"}); err2 != nil {
return "", err
}
}
case "zypper":
if err := pm.Install([]string{"valkey"}); err != nil {
util.Log("valkey not available, trying redis...")
if err2 := pm.Install([]string{"redis"}); err2 != nil {
return "", err
}
}
case "apk":
if err := pm.Install([]string{"valkey"}); err != nil {
util.Log("valkey not available, trying redis...")
if err2 := pm.Install([]string{"redis"}); err2 != nil {
return "", err
}
}
default:
return "", errors.NewPlatformError("No supported package manager for installing KV store.")
}
if bin := util.Which("valkey-server"); bin != "" {
util.Logf("Installed valkey-server: %s", bin)
return bin, nil
}
if bin := util.Which("redis-server"); bin != "" {
util.Logf("Installed redis-server: %s", bin)
return bin, nil
}
return "", errors.NewPlatformError("Could not install redis-compatible server.")
}
func InstallLiveKitBinary(tag, installDir, arch string) (string, error) {
if arch == "" {
var err error
arch, err = DetectArchLinuxRelease()
if err != nil {
return "", err
}
}
url := LiveKitReleaseURL(tag, arch)
binDir := filepath.Join(installDir, "bin")
if err := util.EnsureDir(binDir, 0755, -1, -1); err != nil {
return "", err
}
tmpFile := filepath.Join(binDir, "livekit.tar.gz")
util.Logf("Downloading LiveKit from %s", url)
if _, err := download.DownloadWithOptionalSHA256(url, tmpFile, 30, 2); err != nil {
return "", err
}
if err := extractTarGz(tmpFile, binDir); err != nil {
return "", err
}
var serverPath string
filepath.Walk(binDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
util.Logf("Warning: error walking %s: %v", path, err)
return nil
}
if info.IsDir() {
return nil
}
if info.Name() == "livekit-server" {
serverPath = path
return filepath.SkipAll
}
if strings.Contains(info.Name(), "livekit") && strings.Contains(info.Name(), "server") {
serverPath = path
}
return nil
})
if serverPath == "" {
return "", errors.NewCmdError("Could not find livekit-server after extracting tarball.", nil)
}
target := filepath.Join(binDir, "livekit-server")
if serverPath != target {
if err := util.CopyFile(serverPath, target); err != nil {
if err2 := util.RunSimple([]string{"cp", "-f", serverPath, target}); err2 != nil {
return "", err
}
}
}
os.Chmod(target, 0755)
return target, nil
}
func extractTarGz(tarGzPath, destDir string) error {
f, err := os.Open(tarGzPath)
if err != nil {
return err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Join(destDir, header.Name)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
outFile, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
os.Chmod(target, os.FileMode(header.Mode))
}
}
return nil
}
func EnsureCaddyWithL4(stagingDir, caddyVersion, caddyL4Version, xcaddyVersion, outBin string) error {
if util.FileExists(outBin) {
output, exitCode := util.RunCaptureNoCheck([]string{outBin, "list-modules"})
if exitCode == 0 && strings.Contains(output, "layer4") {
return nil
}
}
if util.Which("go") == "" {
return errors.NewPlatformError("Go is required to build Caddy with caddy-l4.")
}
if util.Which("git") == "" {
return errors.NewPlatformError("git is required to build Caddy with caddy-l4.")
}
env := []string{"GOBIN=/usr/local/bin"}
_, err := util.Run([]string{"bash", "-lc", fmt.Sprintf("go install github.com/caddyserver/xcaddy/cmd/xcaddy@%s", xcaddyVersion)},
util.RunOptions{Check: true, Capture: false, Env: env})
if err != nil {
return err
}
xcaddy := "/usr/local/bin/xcaddy"
if !util.FileExists(xcaddy) {
return errors.NewCmdError("xcaddy install failed.", nil)
}
if err := os.MkdirAll(stagingDir, 0755); err != nil {
return err
}
cmd := []string{
xcaddy,
"build",
caddyVersion,
"--with",
fmt.Sprintf("github.com/mholt/caddy-l4@%s", caddyL4Version),
}
_, err = util.Run(cmd, util.RunOptions{Check: true, Capture: false, Cwd: stagingDir})
if err != nil {
return err
}
built := filepath.Join(stagingDir, "caddy")
if !util.FileExists(built) {
return errors.NewCmdError("xcaddy did not produce a caddy binary.", nil)
}
if err := os.MkdirAll(filepath.Dir(outBin), 0755); err != nil {
return err
}
if err := util.CopyFile(built, outBin); err != nil {
return err
}
os.Chmod(outBin, 0755)
if util.Which("setcap") != "" {
util.Run([]string{"setcap", "cap_net_bind_service=+ep", outBin}, util.RunOptions{Check: false, Capture: true})
}
return nil
}
func DefaultVersions() (string, string, string, string) {
return constants.DefaultLiveKitVersion, constants.DefaultCaddyVersion, constants.DefaultCaddyL4Version, constants.DefaultXcaddyVersion
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package netutil
import (
"io"
"net"
"net/http"
"strings"
"time"
)
func DetectPublicIP(family string) string {
var urls []string
if family == "4" {
urls = []string{"https://api.ipify.org", "https://ipv4.icanhazip.com"}
} else if family == "6" {
urls = []string{"https://api64.ipify.org", "https://ipv6.icanhazip.com"}
}
client := &http.Client{Timeout: 10 * time.Second}
for _, u := range urls {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "livekitctl/0.1")
resp, err := client.Do(req)
if err != nil {
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
ip := strings.TrimSpace(strings.Split(string(body), "\n")[0])
if family == "4" {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
return ip
}
} else {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() == nil {
return ip
}
}
}
return ""
}
func HasGlobalIPv6() bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
if ip.To4() != nil {
continue
}
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
return true
}
}
return false
}
func PrimaryPrivateIPv4() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return ""
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
ip := localAddr.IP.To4()
if ip == nil {
return ""
}
return ip.String()
}
func IsPrivateIPv4(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
ip4 := ip.To4()
if ip4 == nil {
return false
}
return ip.IsPrivate()
}

View File

@@ -0,0 +1,307 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package ops
import (
"fmt"
"os"
"sort"
"strings"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/configgen"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/firewall"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/netutil"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/platform"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/secrets"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/state"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/validate"
)
func secretsPath(st *state.BootstrapState) string {
return st.Paths.SecretsPath
}
func LoadSecrets(st *state.BootstrapState) (*secrets.Secrets, error) {
var sec secrets.Secrets
if err := util.ReadJSON(secretsPath(st), &sec); err != nil {
return nil, err
}
if sec.LiveKitAPIKey == "" {
return nil, errors.NewPlatformError("Secrets file not found. Was bootstrap completed?")
}
return &sec, nil
}
func SaveSecrets(st *state.BootstrapState, sec *secrets.Secrets) error {
return util.WriteJSON(secretsPath(st), sec, 0600, -1, -1)
}
func StatePathDefault() string {
return state.DefaultPaths().StatePath
}
func EnsureLinuxRoot() error {
if !platform.IsLinux() {
return errors.NewPlatformError("This operation is only supported on Linux hosts.")
}
return platform.RequireRoot()
}
func ApplyConfigAndRestart(st *state.BootstrapState, kvBin, publicIPv4, privateIPv4 string) error {
sec, err := LoadSecrets(st)
if err != nil {
return err
}
if err := configgen.WriteAllConfigs(configgen.WriteAllConfigsParams{
State: st,
Secrets: sec,
PublicIPv4: publicIPv4,
PrivateIPv4: privateIPv4,
KVBin: kvBin,
}); err != nil {
return err
}
sm := platform.DetectServiceManager()
if !sm.IsSystemd() {
return errors.NewPlatformError("systemd is required for managed services on this host.")
}
sm.DaemonReload()
sm.Enable("livekit-kv.service")
sm.Enable("livekit-coturn.service")
sm.Enable("livekit.service")
sm.Enable("caddy.service")
sm.Restart("livekit-kv.service")
sm.Restart("livekit-coturn.service")
sm.Restart("livekit.service")
sm.Restart("caddy.service")
return nil
}
func OpStatus(st *state.BootstrapState) string {
sm := platform.DetectServiceManager()
if !sm.IsSystemd() {
return "systemd not detected."
}
var parts []string
for _, svc := range []string{"livekit-kv.service", "livekit-coturn.service", "livekit.service", "caddy.service"} {
parts = append(parts, sm.Status(svc))
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func OpLogs(st *state.BootstrapState, service string, lines int) string {
sm := platform.DetectServiceManager()
if !sm.IsSystemd() {
return "systemd not detected."
}
return sm.Logs(service, lines)
}
func OpRestart(services []string) error {
sm := platform.DetectServiceManager()
if !sm.IsSystemd() {
return errors.NewPlatformError("systemd not detected.")
}
for _, s := range services {
sm.Restart(s)
}
return nil
}
func WebhookList(st *state.BootstrapState) []string {
return st.Webhooks
}
func WebhookAdd(st *state.BootstrapState, url string, allowHTTP bool) (bool, error) {
u, err := validate.RequireWebhookURL(url, allowHTTP)
if err != nil {
return false, err
}
for _, existing := range st.Webhooks {
if existing == u {
return false, nil
}
}
st.Webhooks = append(st.Webhooks, u)
sort.Strings(st.Webhooks)
st.Touch()
return true, state.SaveState(st)
}
func WebhookRemove(st *state.BootstrapState, url string) (bool, error) {
found := false
var newList []string
for _, existing := range st.Webhooks {
if existing == url {
found = true
} else {
newList = append(newList, existing)
}
}
if !found {
return false, nil
}
st.Webhooks = newList
st.Touch()
return true, state.SaveState(st)
}
func WebhookSet(st *state.BootstrapState, urls []string, allowHTTP bool) error {
var cleaned []string
seen := make(map[string]bool)
for _, u := range urls {
validated, err := validate.RequireWebhookURL(u, allowHTTP)
if err != nil {
return err
}
if !seen[validated] {
seen[validated] = true
cleaned = append(cleaned, validated)
}
}
sort.Strings(cleaned)
st.Webhooks = cleaned
st.Touch()
return state.SaveState(st)
}
func RunBasicHealthChecks(st *state.BootstrapState) string {
var out []string
out = append(out, "Listening sockets:")
result, _ := util.Run([]string{"ss", "-lntup"}, util.RunOptions{Check: false, Capture: true})
if result != nil {
out = append(out, strings.TrimSpace(result.Output))
}
result2, _ := util.Run([]string{"curl", "-fsS", fmt.Sprintf("http://127.0.0.1:%d/", st.Ports.LiveKitHTTPLocal)}, util.RunOptions{Check: false, Capture: true})
if result2 != nil && result2.ExitCode == 0 {
out = append(out, "LiveKit local HTTP reachable.")
} else {
out = append(out, "LiveKit local HTTP not reachable.")
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func EnsureStateLoadedOrFail(path string) (*state.BootstrapState, error) {
if path == "" {
path = StatePathDefault()
}
st, err := state.LoadState(path)
if err != nil {
return nil, err
}
if st == nil {
return nil, errors.NewPlatformErrorf("State file not found: %s", path)
}
return st, nil
}
func ConfigureFirewallFromState(st *state.BootstrapState) (string, error) {
tool := firewall.DetectFirewallTool()
msg := firewall.ConfigureFirewall(tool, st.Ports, st.Firewall.Enabled)
st.Firewall.Tool = tool.Name
st.Touch()
if err := state.SaveState(st); err != nil {
return msg, err
}
return msg, nil
}
func DetectPublicIPsOrFail() (string, string, string, error) {
pub4 := netutil.DetectPublicIP("4")
if pub4 == "" {
return "", "", "", errors.NewPlatformError("Could not detect public IPv4.")
}
var pub6 string
if netutil.HasGlobalIPv6() {
pub6 = netutil.DetectPublicIP("6")
}
priv4 := netutil.PrimaryPrivateIPv4()
return pub4, pub6, priv4, nil
}
func ReadLinesFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.NewPlatformErrorf("File not found: %s", path)
}
var lines []string
for _, line := range strings.Split(string(data), "\n") {
s := strings.TrimSpace(line)
if s != "" {
lines = append(lines, s)
}
}
return lines, nil
}
// StopConflictingServices stops system-installed services that conflict with managed ones
func StopConflictingServices() {
sm := platform.DetectServiceManager()
if !sm.IsSystemd() {
return
}
conflicting := []string{
"valkey-server.service",
"valkey.service",
"redis-server.service",
"redis.service",
"coturn.service",
}
for _, svc := range conflicting {
util.Run([]string{"systemctl", "stop", svc}, util.RunOptions{Check: false, Capture: true})
util.Run([]string{"systemctl", "disable", svc}, util.RunOptions{Check: false, Capture: true})
}
managed := []string{
"livekit-kv.service",
"livekit-coturn.service",
"livekit.service",
"caddy.service",
}
for _, svc := range managed {
util.Run([]string{"systemctl", "reset-failed", svc}, util.RunOptions{Check: false, Capture: true})
}
}

View File

@@ -0,0 +1,218 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package platform
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
func IsLinux() bool {
return runtime.GOOS == "linux"
}
func RequireRoot() error {
if os.Geteuid() != 0 {
return errors.NewPlatformError("Run as root (sudo -i).")
}
return nil
}
func ReadOSRelease() map[string]string {
data := make(map[string]string)
f, err := os.Open("/etc/os-release")
if err != nil {
return data
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
key := parts[0]
value := strings.Trim(parts[1], `"`)
data[key] = value
}
return data
}
type PlatformInfo struct {
ID string
IDLike string
Pretty string
}
func DetectPlatform() PlatformInfo {
osr := ReadOSRelease()
return PlatformInfo{
ID: strings.ToLower(osr["ID"]),
IDLike: strings.ToLower(osr["ID_LIKE"]),
Pretty: strings.TrimSpace(osr["PRETTY_NAME"]),
}
}
type PackageManager struct {
Kind string
}
func DetectPackageManager() *PackageManager {
if util.Which("apt-get") != "" {
return &PackageManager{Kind: "apt"}
}
if util.Which("dnf") != "" {
return &PackageManager{Kind: "dnf"}
}
if util.Which("yum") != "" {
return &PackageManager{Kind: "yum"}
}
if util.Which("pacman") != "" {
return &PackageManager{Kind: "pacman"}
}
if util.Which("zypper") != "" {
return &PackageManager{Kind: "zypper"}
}
if util.Which("apk") != "" {
return &PackageManager{Kind: "apk"}
}
return nil
}
func (pm *PackageManager) Install(pkgs []string) error {
if len(pkgs) == 0 {
return nil
}
switch pm.Kind {
case "apt":
if err := util.RunSimple([]string{"apt-get", "update"}); err != nil {
return err
}
args := append([]string{"apt-get", "install", "-y", "--no-install-recommends"}, pkgs...)
return util.RunSimple(args)
case "dnf":
args := append([]string{"dnf", "-y", "install"}, pkgs...)
return util.RunSimple(args)
case "yum":
args := append([]string{"yum", "-y", "install"}, pkgs...)
return util.RunSimple(args)
case "pacman":
args := append([]string{"pacman", "-Sy", "--noconfirm"}, pkgs...)
return util.RunSimple(args)
case "zypper":
args := append([]string{"zypper", "--non-interactive", "install"}, pkgs...)
return util.RunSimple(args)
case "apk":
args := append([]string{"apk", "add", "--no-cache"}, pkgs...)
return util.RunSimple(args)
}
return errors.NewPlatformErrorf("Unsupported package manager: %s", pm.Kind)
}
type ServiceManager struct {
Kind string
}
func DetectServiceManager() *ServiceManager {
if util.Which("systemctl") != "" && util.FileExists("/run/systemd/system") {
return &ServiceManager{Kind: "systemd"}
}
return &ServiceManager{Kind: "none"}
}
func (sm *ServiceManager) IsSystemd() bool {
return sm.Kind == "systemd"
}
func (sm *ServiceManager) DaemonReload() {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "daemon-reload"}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Enable(name string) {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "enable", name}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Disable(name string) {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "disable", name}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Restart(name string) {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "restart", name}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Start(name string) {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "start", name}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Stop(name string) {
if sm.Kind == "systemd" {
util.Run([]string{"systemctl", "stop", name}, util.RunOptions{Check: false, Capture: true})
}
}
func (sm *ServiceManager) Status(name string) string {
if sm.Kind != "systemd" {
return "Service manager not available."
}
result, _ := util.Run([]string{"systemctl", "status", name, "--no-pager"}, util.RunOptions{Check: false, Capture: true})
if result != nil {
return strings.TrimSpace(result.Output)
}
return ""
}
func (sm *ServiceManager) Logs(name string, lines int) string {
if sm.Kind != "systemd" {
return "Service manager not available."
}
if lines < 1 {
lines = 1
}
result, _ := util.Run([]string{"journalctl", "-u", name, "-n", fmt.Sprintf("%d", lines), "--no-pager"}, util.RunOptions{Check: false, Capture: true})
if result != nil {
return strings.TrimSpace(result.Output)
}
return ""
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package secrets
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
)
func RandomTokenURLSafe(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand.Read failed: " + err.Error())
}
return base64.URLEncoding.EncodeToString(b)
}
func RandomTokenHex(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand.Read failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func SafeAPIKey(prefix string, nbytes int) string {
return prefix + RandomTokenHex(nbytes)
}
type Secrets struct {
KVPassword string `json:"kv_password"`
LiveKitAPIKey string `json:"livekit_api_key"`
LiveKitAPISecret string `json:"livekit_api_secret"`
TURNUsername string `json:"turn_username"`
TURNPassword string `json:"turn_password"`
BlueskyOAuthPrivateKey string `json:"bluesky_oauth_private_key"`
BlueskyOAuthKeyID string `json:"bluesky_oauth_key_id"`
}
func GenerateBlueskyOAuthRSAKey() (string, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
return string(privateKeyPEM), nil
}
func GenerateNewSecrets() *Secrets {
blueskyPrivateKey, err := GenerateBlueskyOAuthRSAKey()
if err != nil {
panic("Failed to generate Bluesky OAuth RSA key: " + err.Error())
}
return &Secrets{
KVPassword: RandomTokenURLSafe(24),
LiveKitAPIKey: SafeAPIKey("lk_", 16),
LiveKitAPISecret: RandomTokenURLSafe(48),
TURNUsername: "livekit",
TURNPassword: RandomTokenURLSafe(48),
BlueskyOAuthPrivateKey: blueskyPrivateKey,
BlueskyOAuthKeyID: "prod-key-1",
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package state
import (
"encoding/json"
"os"
"sort"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/constants"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
type Versions struct {
LiveKit string `json:"livekit"`
Caddy string `json:"caddy"`
CaddyL4 string `json:"caddy_l4"`
Xcaddy string `json:"xcaddy"`
}
type Domains struct {
LiveKit string `json:"livekit"`
TURN string `json:"turn"`
}
type Paths struct {
ConfigDir string `json:"config_dir"`
StatePath string `json:"state_path"`
SecretsPath string `json:"secrets_path"`
LiveKitInstallDir string `json:"livekit_install_dir"`
LiveKitBinDir string `json:"livekit_bin_dir"`
CaddyBin string `json:"caddy_bin"`
CaddyStorageDir string `json:"caddy_storage_dir"`
CaddyLogDir string `json:"caddy_log_dir"`
LiveKitLogDir string `json:"livekit_log_dir"`
KVDataDir string `json:"kv_data_dir"`
KVLogDir string `json:"kv_log_dir"`
UnitDir string `json:"unit_dir"`
}
func DefaultPaths() Paths {
return Paths{
ConfigDir: "/etc/livekit",
StatePath: "/etc/livekit/livekitctl-state.json",
SecretsPath: "/etc/livekit/livekitctl-secrets.json",
LiveKitInstallDir: "/opt/livekit",
LiveKitBinDir: "/opt/livekit/bin",
CaddyBin: "/usr/local/bin/caddy",
CaddyStorageDir: "/var/lib/caddy",
CaddyLogDir: "/var/log/caddy",
LiveKitLogDir: "/var/log/livekit",
KVDataDir: "/var/lib/livekit/kv",
KVLogDir: "/var/log/livekit",
UnitDir: "/etc/systemd/system",
}
}
type KVConfig struct {
BindHost string `json:"bind_host"`
Port int `json:"port"`
}
type FirewallConfig struct {
Enabled bool `json:"enabled"`
Tool string `json:"tool"`
}
type BootstrapState struct {
SchemaVersion int `json:"schema_version"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ACMEEmail string `json:"acme_email"`
Domains Domains `json:"domains"`
Ports constants.Ports `json:"ports"`
Versions Versions `json:"versions"`
KV KVConfig `json:"kv"`
Webhooks []string `json:"webhooks"`
Paths Paths `json:"paths"`
Firewall FirewallConfig `json:"firewall"`
}
type NewStateParams struct {
ACMEEmail string
Domains Domains
Ports constants.Ports
Versions Versions
KV KVConfig
Webhooks []string
Firewall FirewallConfig
Paths *Paths
}
func NewState(params NewStateParams) *BootstrapState {
ts := util.NowRFC3339()
webhooks := params.Webhooks
if webhooks == nil {
webhooks = []string{}
}
unique := make(map[string]bool)
for _, w := range webhooks {
unique[w] = true
}
sorted := make([]string, 0, len(unique))
for w := range unique {
sorted = append(sorted, w)
}
sort.Strings(sorted)
paths := DefaultPaths()
if params.Paths != nil {
paths = *params.Paths
}
return &BootstrapState{
SchemaVersion: 1,
CreatedAt: ts,
UpdatedAt: ts,
ACMEEmail: params.ACMEEmail,
Domains: params.Domains,
Ports: params.Ports,
Versions: params.Versions,
KV: params.KV,
Webhooks: sorted,
Paths: paths,
Firewall: params.Firewall,
}
}
func (st *BootstrapState) Touch() {
st.UpdatedAt = util.NowRFC3339()
}
func LoadState(path string) (*BootstrapState, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var st BootstrapState
if err := json.Unmarshal(data, &st); err != nil {
return nil, err
}
return &st, nil
}
func SaveState(st *BootstrapState) error {
return util.WriteJSON(st.Paths.StatePath, st, 0600, -1, -1)
}

View File

@@ -0,0 +1,258 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package util
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"time"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
)
func Log(msg string) {
fmt.Println(msg)
}
func Logf(format string, args ...interface{}) {
fmt.Printf(format+"\n", args...)
}
func Which(binName string) string {
path, err := exec.LookPath(binName)
if err != nil {
return ""
}
return path
}
type RunOptions struct {
Check bool
Capture bool
Env []string
Cwd string
}
type RunResult struct {
ExitCode int
Output string
}
func Run(cmd []string, opts RunOptions) (*RunResult, error) {
if len(cmd) == 0 {
return nil, errors.NewCmdError("empty command", nil)
}
c := exec.Command(cmd[0], cmd[1:]...)
if opts.Cwd != "" {
c.Dir = opts.Cwd
}
if len(opts.Env) > 0 {
c.Env = append(os.Environ(), opts.Env...)
}
var output bytes.Buffer
if opts.Capture {
c.Stdout = &output
c.Stderr = &output
} else {
c.Stdout = os.Stdout
c.Stderr = os.Stderr
}
err := c.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return nil, errors.NewCmdError(fmt.Sprintf("command not found: %s", cmd[0]), err)
}
}
result := &RunResult{
ExitCode: exitCode,
Output: output.String(),
}
if opts.Check && exitCode != 0 {
return result, errors.NewCmdError(
fmt.Sprintf("command failed (%d): %v\n%s", exitCode, cmd, result.Output),
nil,
)
}
return result, nil
}
func RunSimple(cmd []string) error {
_, err := Run(cmd, RunOptions{Check: true, Capture: false})
return err
}
func RunCapture(cmd []string) (string, error) {
result, err := Run(cmd, RunOptions{Check: true, Capture: true})
if err != nil {
return "", err
}
return result.Output, nil
}
func RunCaptureNoCheck(cmd []string) (string, int) {
result, _ := Run(cmd, RunOptions{Check: false, Capture: true})
if result == nil {
return "", -1
}
return result.Output, result.ExitCode
}
func AtomicWriteText(path string, content string, mode os.FileMode, uid, gid int) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
tmpFile, err := os.CreateTemp(dir, ".tmp-")
if err != nil {
return err
}
tmpName := tmpFile.Name()
_, err = tmpFile.WriteString(content)
if err != nil {
tmpFile.Close()
os.Remove(tmpName)
return err
}
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
os.Remove(tmpName)
return err
}
tmpFile.Close()
if err := os.Chmod(tmpName, mode); err != nil {
os.Remove(tmpName)
return err
}
if uid >= 0 || gid >= 0 {
if err := os.Chown(tmpName, uid, gid); err != nil {
os.Remove(tmpName)
return err
}
}
return os.Rename(tmpName, path)
}
func ReadJSON(path string, v interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, v)
}
func WriteJSON(path string, v interface{}, mode os.FileMode, uid, gid int) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
content := string(data) + "\n"
return AtomicWriteText(path, content, mode, uid, gid)
}
func NowRFC3339() string {
return time.Now().UTC().Format(time.RFC3339)
}
func EnsureDir(path string, mode os.FileMode, uid, gid int) error {
if err := os.MkdirAll(path, mode); err != nil {
return err
}
if err := os.Chmod(path, mode); err != nil {
return err
}
if uid >= 0 || gid >= 0 {
if err := os.Chown(path, uid, gid); err != nil {
return err
}
}
return nil
}
type UserGroup struct {
UID int
GID int
}
func LookupUserGroup(username string) *UserGroup {
u, err := user.Lookup(username)
if err != nil {
return nil
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return nil
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
return nil
}
return &UserGroup{UID: uid, GID: gid}
}
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func CopyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package validate
import (
"net/url"
"regexp"
"strings"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/errors"
)
var labelRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
var tldRE = regexp.MustCompile(`^[a-z]{2,63}$`)
func RequireDomain(name, field string) (string, error) {
name = strings.TrimSpace(strings.ToLower(name))
if len(name) < 1 || len(name) > 253 {
return "", errors.NewValidationError("Invalid " + field + ": " + name)
}
parts := strings.Split(name, ".")
if len(parts) < 2 {
return "", errors.NewValidationError("Invalid " + field + ": " + name)
}
for i, part := range parts {
if len(part) < 1 || len(part) > 63 {
return "", errors.NewValidationError("Invalid " + field + ": " + name)
}
if i == len(parts)-1 {
if !tldRE.MatchString(part) {
return "", errors.NewValidationError("Invalid " + field + ": " + name)
}
} else {
if !labelRE.MatchString(part) {
return "", errors.NewValidationError("Invalid " + field + ": " + name)
}
}
}
return name, nil
}
func RequireEmail(email string) (string, error) {
email = strings.TrimSpace(email)
if !strings.Contains(email, "@") || !strings.Contains(email, ".") ||
strings.HasPrefix(email, "@") || strings.HasSuffix(email, "@") {
return "", errors.NewValidationError("Email does not look valid.")
}
return email, nil
}
var versionRE = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
var branchRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
func NormaliseVersionTag(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "latest" {
return v, nil
}
if strings.HasPrefix(v, "v") {
return v, nil
}
if versionRE.MatchString(v) {
return "v" + v, nil
}
if branchRE.MatchString(v) {
return v, nil
}
return "", errors.NewValidationError("Invalid version: " + v)
}
func RequireWebhookURL(urlStr string, allowHTTP bool) (string, error) {
u := strings.TrimSpace(urlStr)
parsed, err := url.Parse(u)
if err != nil {
return "", errors.NewValidationError("Invalid webhook URL: " + u)
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return "", errors.NewValidationError("Invalid webhook URL scheme: " + u)
}
if parsed.Scheme == "http" && !allowHTTP {
return "", errors.NewValidationError("Refusing insecure webhook URL: " + u)
}
if parsed.Host == "" {
return "", errors.NewValidationError("Invalid webhook URL host: " + u)
}
return u, nil
}