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,172 @@
# livekitctl
A CLI tool for bootstrapping self-hosted LiveKit SFU infrastructure for Fluxer voice and video.
## Installation
```bash
curl -fsSL https://fluxer.app/get/livekitctl | sudo bash
```
## Overview
livekitctl automates the installation and configuration of a complete LiveKit media server stack including:
- **LiveKit** - WebRTC SFU for voice and video
- **Caddy** - Reverse proxy with automatic TLS (built with L4 module for TCP/UDP)
- **coturn** - TURN/STUN server for NAT traversal
- **KV store** - Redis-compatible key-value store for LiveKit state
## Prerequisites
- Linux server (Debian/Ubuntu, RHEL/CentOS, or Arch-based)
- Root access
- DNS records configured for your LiveKit and TURN domains pointing to your server's public IP
## Commands
### bootstrap
Install and configure the complete LiveKit stack.
```bash
livekitctl bootstrap \
--livekit-domain livekit.example.com \
--turn-domain turn.example.com \
--email admin@example.com
```
Required flags:
- `--livekit-domain <domain>` - Domain for LiveKit WebSocket/HTTP connections
- `--turn-domain <domain>` - Domain for TURN relay server
- `--email <email>` - ACME email for TLS certificate issuance
Optional flags:
- `--livekit-version <version>` - LiveKit version (default: v1.9.11)
- `--caddy-version <version>` - Caddy version (default: v2.10.2)
- `--caddy-l4-version <version>` - Caddy L4 module version (default: master)
- `--xcaddy-version <version>` - xcaddy build tool version (default: v0.4.5)
- `--install-dir <path>` - Override LiveKit install directory (default: /opt/livekit)
- `--firewall` - Configure detected firewall tool (ufw, firewalld, iptables)
- `--kv-port <port>` - KV store port (default: 6379)
- `--kv-port-auto` - Pick a free KV port from 6379-6382
- `--webhook-url <url>` - Webhook URL (repeatable)
- `--webhook-urls-file <file>` - File with webhook URLs (one per line)
- `--allow-http-webhooks` - Allow http:// webhook URLs
- `--dns-timeout <seconds>` - DNS wait timeout (default: 900)
- `--dns-interval <seconds>` - DNS check interval (default: 10)
- `--print-secrets` - Print generated secrets JSON to stdout
### status
Show systemd service status for all managed services.
```bash
livekitctl status
```
### logs
Show systemd logs for a specific service.
```bash
livekitctl logs --service livekit.service [--lines 200]
```
Flags:
- `--service <unit>` - systemd unit name (required), e.g., `livekit.service`, `caddy.service`
- `--lines <n>` - Number of log lines to show (default: 200)
### restart
Restart one or more services. If no services specified, restarts all managed services.
```bash
livekitctl restart [services...]
```
Examples:
```bash
livekitctl restart # Restart all services
livekitctl restart livekit.service # Restart only LiveKit
livekitctl restart caddy.service livekit-coturn.service
```
Managed services:
- `livekit-kv.service` - KV store
- `livekit-coturn.service` - TURN server
- `livekit.service` - LiveKit SFU
- `caddy.service` - Reverse proxy
### webhook
Manage LiveKit webhook URLs. Changes are written to config and LiveKit is restarted.
```bash
livekitctl webhook list
livekitctl webhook add <url> [--allow-http-webhooks]
livekitctl webhook remove <url>
livekitctl webhook set --url <url> [--url <url>...] [--file <path>] [--allow-http-webhooks]
```
Subcommands:
- `list` - List configured webhook URLs
- `add <url>` - Add a webhook URL
- `remove <url>` - Remove a webhook URL
- `set` - Replace all webhook URLs
## Port configuration
Default port allocations:
| Port | Protocol | Service |
| ----------- | -------- | ------------------------- |
| 7880 | TCP | LiveKit HTTP (internal) |
| 7881 | TCP | LiveKit RTC |
| 50000-60000 | UDP | LiveKit RTC media |
| 3478 | UDP | TURN listen |
| 40000-49999 | UDP | TURN relay |
| 6379 | TCP | KV store (localhost only) |
## State and configuration files
```
/etc/livekit/
livekitctl-state.json # Bootstrap state
secrets.json # Generated API keys and secrets
livekit.yaml # LiveKit server config
caddy.json # Caddy config
coturn.conf # TURN server config
/opt/livekit/
bin/
livekit-server # LiveKit binary
```
## DNS setup
Before running bootstrap, create DNS records pointing to your server's public IP:
```
A livekit.example.com → <your-ipv4>
A turn.example.com → <your-ipv4>
```
If your server has IPv6:
```
AAAA livekit.example.com → <your-ipv6>
AAAA turn.example.com → <your-ipv6>
```
The bootstrap command waits for DNS propagation before requesting TLS certificates.
## Global flags
- `--state <path>` - Path to state file (default: /etc/livekit/livekitctl-state.json)

View File

@@ -0,0 +1,272 @@
/*
* 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 cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/constants"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/dnswait"
"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/install"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/netutil"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/ops"
"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"
)
var bootstrapCmd = &cobra.Command{
Use: "bootstrap",
Short: "Install and configure LiveKit, Caddy (l4), coturn, and KV store",
Run: runBootstrap,
}
var (
livekitDomain string
turnDomain string
email string
livekitVersion string
caddyVersion string
caddyL4Version string
xcaddyVersion string
installDir string
enableFirewall bool
kvPort int
kvPortAuto bool
webhookURLs []string
webhookURLsFile string
allowHTTPWebhooks bool
dnsTimeout int
dnsInterval int
printSecrets bool
)
func init() {
rootCmd.AddCommand(bootstrapCmd)
bootstrapCmd.Flags().StringVar(&livekitDomain, "livekit-domain", "", "LiveKit domain (required)")
bootstrapCmd.Flags().StringVar(&turnDomain, "turn-domain", "", "TURN domain (required)")
bootstrapCmd.Flags().StringVar(&email, "email", "", "ACME email (required)")
bootstrapCmd.Flags().StringVar(&livekitVersion, "livekit-version", constants.DefaultLiveKitVersion, "LiveKit version")
bootstrapCmd.Flags().StringVar(&caddyVersion, "caddy-version", constants.DefaultCaddyVersion, "Caddy version")
bootstrapCmd.Flags().StringVar(&caddyL4Version, "caddy-l4-version", constants.DefaultCaddyL4Version, "Caddy L4 version")
bootstrapCmd.Flags().StringVar(&xcaddyVersion, "xcaddy-version", constants.DefaultXcaddyVersion, "xcaddy version")
bootstrapCmd.Flags().StringVar(&installDir, "install-dir", "", "Override LiveKit install dir (default: /opt/livekit)")
bootstrapCmd.Flags().BoolVar(&enableFirewall, "firewall", false, "Configure detected firewall tool")
bootstrapCmd.Flags().IntVar(&kvPort, "kv-port", 0, "KV port (default: 6379)")
bootstrapCmd.Flags().BoolVar(&kvPortAuto, "kv-port-auto", false, "Pick a free KV port from 6379-6382")
bootstrapCmd.Flags().StringArrayVar(&webhookURLs, "webhook-url", nil, "Webhook URL (repeatable)")
bootstrapCmd.Flags().StringVar(&webhookURLsFile, "webhook-urls-file", "", "File with webhook URLs (one per line)")
bootstrapCmd.Flags().BoolVar(&allowHTTPWebhooks, "allow-http-webhooks", false, "Allow http:// webhook URLs")
bootstrapCmd.Flags().IntVar(&dnsTimeout, "dns-timeout", 900, "DNS wait timeout in seconds")
bootstrapCmd.Flags().IntVar(&dnsInterval, "dns-interval", 10, "DNS check interval in seconds")
bootstrapCmd.Flags().BoolVar(&printSecrets, "print-secrets", false, "Print secrets JSON to stdout")
bootstrapCmd.MarkFlagRequired("livekit-domain")
bootstrapCmd.MarkFlagRequired("turn-domain")
bootstrapCmd.MarkFlagRequired("email")
}
func runBootstrap(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
livekitDomainValidated, err := validate.RequireDomain(livekitDomain, "livekit domain")
exitOnError(err)
turnDomainValidated, err := validate.RequireDomain(turnDomain, "turn domain")
exitOnError(err)
acmeEmail, err := validate.RequireEmail(email)
exitOnError(err)
ports := constants.DefaultPorts()
if kvPort > 0 {
ports.KVPort = kvPort
}
livekitVersionValidated, err := validate.NormaliseVersionTag(livekitVersion)
exitOnError(err)
caddyVersionValidated, err := validate.NormaliseVersionTag(caddyVersion)
exitOnError(err)
caddyL4VersionValidated, err := validate.NormaliseVersionTag(caddyL4Version)
exitOnError(err)
xcaddyVersionValidated, err := validate.NormaliseVersionTag(xcaddyVersion)
exitOnError(err)
var webhooks []string
for _, u := range webhookURLs {
validated, err := validate.RequireWebhookURL(u, allowHTTPWebhooks)
exitOnError(err)
webhooks = append(webhooks, validated)
}
if webhookURLsFile != "" {
lines, err := ops.ReadLinesFile(webhookURLsFile)
exitOnError(err)
for _, u := range lines {
validated, err := validate.RequireWebhookURL(u, allowHTTPWebhooks)
exitOnError(err)
webhooks = append(webhooks, validated)
}
}
pm := platform.DetectPackageManager()
if pm == nil {
exitOnError(errors.NewPlatformError("No supported package manager detected."))
}
exitOnError(install.InstallBasePackages(pm))
exitOnError(install.EnsureUsers())
kvBin, err := install.InstallKVBinary(pm)
exitOnError(err)
paths := state.DefaultPaths()
if installDir != "" {
paths.LiveKitInstallDir = installDir
paths.LiveKitBinDir = filepath.Join(installDir, "bin")
}
if kvPortAuto {
for _, cand := range []int{6379, 6380, 6381, 6382} {
output, exitCode := util.RunCaptureNoCheck([]string{"bash", "-lc", fmt.Sprintf("ss -lnt | awk '{print $4}' | grep -q ':%d$'", cand)})
_ = output
if exitCode != 0 {
ports.KVPort = cand
break
}
}
}
fwTool := firewall.DetectFirewallTool()
firewallCfg := state.FirewallConfig{Enabled: enableFirewall, Tool: fwTool.Name}
st := state.NewState(state.NewStateParams{
ACMEEmail: acmeEmail,
Domains: state.Domains{
LiveKit: livekitDomainValidated,
TURN: turnDomainValidated,
},
Ports: ports,
Versions: state.Versions{
LiveKit: livekitVersionValidated,
Caddy: caddyVersionValidated,
CaddyL4: caddyL4VersionValidated,
Xcaddy: xcaddyVersionValidated,
},
KV: state.KVConfig{
BindHost: ports.KVBindHost,
Port: ports.KVPort,
},
Webhooks: webhooks,
Firewall: firewallCfg,
Paths: &paths,
})
exitOnError(os.MkdirAll(st.Paths.ConfigDir, 0755))
sec := secrets.GenerateNewSecrets()
exitOnError(ops.SaveSecrets(st, sec))
pub4 := netutil.DetectPublicIP("4")
if pub4 == "" {
exitOnError(errors.NewPlatformError("Could not detect public IPv4."))
}
var pub6 string
if netutil.HasGlobalIPv6() {
pub6 = netutil.DetectPublicIP("6")
}
priv4 := netutil.PrimaryPrivateIPv4()
util.Log("")
util.Log("DNS records needed before TLS issuance:")
util.Logf("A %s -> %s", livekitDomainValidated, pub4)
util.Logf("A %s -> %s", turnDomainValidated, pub4)
if pub6 != "" {
util.Logf("AAAA %s -> %s", livekitDomainValidated, pub6)
util.Logf("AAAA %s -> %s", turnDomainValidated, pub6)
}
util.Log("")
okDNS := dnswait.WaitForDNS(livekitDomainValidated, turnDomainValidated, pub4, pub6, dnsTimeout, dnsInterval)
if !okDNS {
util.Log("DNS not verified yet. Continuing. ACME may fail until DNS is correct.")
}
_, err = install.InstallLiveKitBinary(livekitVersionValidated, st.Paths.LiveKitInstallDir, "")
exitOnError(err)
exitOnError(install.EnsureCaddyWithL4(
"/tmp/livekitctl-caddy-build",
caddyVersionValidated,
caddyL4VersionValidated,
xcaddyVersionValidated,
st.Paths.CaddyBin,
))
exitOnError(state.SaveState(st))
ops.StopConflictingServices()
exitOnError(ops.ApplyConfigAndRestart(st, kvBin, pub4, priv4))
if st.Firewall.Enabled {
msg, err := ops.ConfigureFirewallFromState(st)
exitOnError(err)
util.Log(msg)
}
util.Log("")
util.Log("Bootstrap completed.")
util.Log("")
util.Log("State:")
util.Logf(" %s", st.Paths.StatePath)
util.Log("Secrets:")
util.Logf(" %s", st.Paths.SecretsPath)
util.Log("")
if printSecrets {
data, err := os.ReadFile(st.Paths.SecretsPath)
if err == nil {
util.Log("Secrets JSON:")
util.Log(strings.TrimSpace(string(data)))
util.Log("")
}
}
util.Log(ops.RunBasicHealthChecks(st))
}

View File

@@ -0,0 +1,56 @@
/*
* 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 cmd
import (
"github.com/spf13/cobra"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/ops"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
var logsCmd = &cobra.Command{
Use: "logs",
Short: "Show systemd logs",
Run: runLogs,
}
var (
logsService string
logsLines int
)
func init() {
rootCmd.AddCommand(logsCmd)
logsCmd.Flags().StringVar(&logsService, "service", "", "systemd unit, eg livekit.service (required)")
logsCmd.Flags().IntVar(&logsLines, "lines", 200, "Number of log lines")
logsCmd.MarkFlagRequired("service")
}
func runLogs(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
util.Log(ops.OpLogs(st, logsService, logsLines))
}

View File

@@ -0,0 +1,50 @@
/*
* 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 cmd
import (
"github.com/spf13/cobra"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/ops"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
var restartCmd = &cobra.Command{
Use: "restart [services...]",
Short: "Restart one or more services",
Long: "Restart one or more services. If no services specified, restarts all managed services.",
Run: runRestart,
}
func init() {
rootCmd.AddCommand(restartCmd)
}
func runRestart(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
services := args
if len(services) == 0 {
services = []string{"livekit-kv.service", "livekit-coturn.service", "livekit.service", "caddy.service"}
}
exitOnError(ops.OpRestart(services))
util.Log("Restart requested.")
}

View File

@@ -0,0 +1,50 @@
/*
* 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 cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var statePath string
var rootCmd = &cobra.Command{
Use: "livekitctl",
Short: "LiveKit bootstrap and operations CLI",
Long: "Self-hosted LiveKit bootstrap and operations CLI for installing and managing LiveKit servers.",
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
rootCmd.PersistentFlags().StringVar(&statePath, "state", "", "Path to state file (default: /etc/livekit/livekitctl-state.json)")
}
func exitOnError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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 cmd
import (
"github.com/spf13/cobra"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/ops"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show systemd status for managed services",
Run: runStatus,
}
func init() {
rootCmd.AddCommand(statusCmd)
}
func runStatus(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
util.Log(ops.OpStatus(st))
}

View File

@@ -0,0 +1,172 @@
/*
* 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 cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/install"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/netutil"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/ops"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/platform"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/state"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/internal/util"
)
var webhookCmd = &cobra.Command{
Use: "webhook",
Short: "Manage LiveKit webhooks (writes config and restarts LiveKit)",
}
var webhookListCmd = &cobra.Command{
Use: "list",
Short: "List webhook URLs",
Run: runWebhookList,
}
var webhookAddCmd = &cobra.Command{
Use: "add <url>",
Short: "Add a webhook URL",
Args: cobra.ExactArgs(1),
Run: runWebhookAdd,
}
var webhookRemoveCmd = &cobra.Command{
Use: "remove <url>",
Short: "Remove a webhook URL",
Args: cobra.ExactArgs(1),
Run: runWebhookRemove,
}
var webhookSetCmd = &cobra.Command{
Use: "set",
Short: "Replace webhook URLs",
Run: runWebhookSet,
}
var (
webhookAllowHTTP bool
webhookSetURLs []string
webhookSetFile string
)
func init() {
rootCmd.AddCommand(webhookCmd)
webhookCmd.AddCommand(webhookListCmd)
webhookAddCmd.Flags().BoolVar(&webhookAllowHTTP, "allow-http-webhooks", false, "Allow http:// webhook URLs")
webhookCmd.AddCommand(webhookAddCmd)
webhookCmd.AddCommand(webhookRemoveCmd)
webhookSetCmd.Flags().StringArrayVar(&webhookSetURLs, "url", nil, "Webhook URL (repeatable)")
webhookSetCmd.Flags().StringVar(&webhookSetFile, "file", "", "File with webhook URLs (one per line)")
webhookSetCmd.Flags().BoolVar(&webhookAllowHTTP, "allow-http-webhooks", false, "Allow http:// webhook URLs")
webhookCmd.AddCommand(webhookSetCmd)
}
func runWebhookList(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
for _, u := range ops.WebhookList(st) {
fmt.Println(u)
}
}
func runWebhookAdd(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
changed, err := ops.WebhookAdd(st, args[0], webhookAllowHTTP)
exitOnError(err)
if changed {
exitOnError(applyAndRestart(st))
util.Log("Webhook added and LiveKit restarted.")
} else {
util.Log("Webhook already present.")
}
}
func runWebhookRemove(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
changed, err := ops.WebhookRemove(st, args[0])
exitOnError(err)
if changed {
exitOnError(applyAndRestart(st))
util.Log("Webhook removed and LiveKit restarted.")
} else {
util.Log("Webhook not found.")
}
}
func runWebhookSet(cmd *cobra.Command, args []string) {
exitOnError(ops.EnsureLinuxRoot())
st, err := ops.EnsureStateLoadedOrFail(statePath)
exitOnError(err)
var urls []string
urls = append(urls, webhookSetURLs...)
if webhookSetFile != "" {
lines, err := ops.ReadLinesFile(webhookSetFile)
exitOnError(err)
urls = append(urls, lines...)
}
exitOnError(ops.WebhookSet(st, urls, webhookAllowHTTP))
exitOnError(applyAndRestart(st))
util.Log("Webhooks updated and LiveKit restarted.")
}
func applyAndRestart(st *state.BootstrapState) error {
pm := platform.DetectPackageManager()
if pm == nil {
return fmt.Errorf("no supported package manager detected")
}
kvBin, err := install.InstallKVBinary(pm)
if err != nil {
return err
}
pub4 := netutil.DetectPublicIP("4")
if pub4 == "" {
util.Log("Warning: Could not detect public IPv4, using 0.0.0.0")
pub4 = "0.0.0.0"
}
priv4 := netutil.PrimaryPrivateIPv4()
return ops.ApplyConfigAndRestart(st, kvBin, pub4, priv4)
}

View File

@@ -0,0 +1,10 @@
module github.com/fluxerapp/fluxer/fluxer_devops/livekitctl
go 1.24
require github.com/spf13/cobra v1.8.1
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

View File

@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

View File

@@ -0,0 +1,32 @@
/*
* 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 main
import (
"os"
"github.com/fluxerapp/fluxer/fluxer_devops/livekitctl/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env sh
# 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/>.
set -eu
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
GITHUB_REPO="fluxerapp/fluxer"
BINARY_NAME="livekitctl"
info() {
echo "[livekitctl] $*"
}
error() {
echo "[livekitctl] ERROR: $*" >&2
exit 1
}
check_root() {
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)"
fi
}
detect_arch() {
arch=$(uname -m)
case "$arch" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
error "Unsupported architecture: $arch"
;;
esac
}
detect_os() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$os" in
linux)
echo "linux"
;;
*)
error "Unsupported OS: $os (livekitctl only supports Linux)"
;;
esac
}
get_latest_version() {
version=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases" | \
grep -oP '"tag_name":\s*"livekitctl-v\K[0-9]+\.[0-9]+\.[0-9]+' | \
head -1)
if [ -z "$version" ]; then
error "Failed to determine latest version"
fi
echo "$version"
}
download_binary() {
version="$1"
os="$2"
arch="$3"
url="https://github.com/${GITHUB_REPO}/releases/download/livekitctl-v${version}/${BINARY_NAME}-${os}-${arch}"
tmp_file=$(mktemp)
info "Downloading livekitctl v${version} for ${os}/${arch}..."
if ! curl -fsSL "$url" -o "$tmp_file"; then
rm -f "$tmp_file"
error "Failed to download from $url"
fi
echo "$tmp_file"
}
install_binary() {
tmp_file="$1"
dest="${INSTALL_DIR}/${BINARY_NAME}"
info "Installing to ${dest}..."
mv "$tmp_file" "$dest"
chmod 755 "$dest"
}
verify_installation() {
if command -v "$BINARY_NAME" >/dev/null 2>&1; then
info "Successfully installed livekitctl"
"$BINARY_NAME" --help | head -5
else
error "Installation failed - binary not found in PATH"
fi
}
main() {
info "livekitctl installer"
info ""
check_root
os=$(detect_os)
arch=$(detect_arch)
info "Detected: ${os}/${arch}"
version=$(get_latest_version)
tmp_file=$(download_binary "$version" "$os" "$arch")
install_binary "$tmp_file"
verify_installation
info ""
info "Run 'livekitctl bootstrap --help' to get started"
}
main "$@"