refactor progress
This commit is contained in:
172
fluxer_devops/livekitctl/README.md
Normal file
172
fluxer_devops/livekitctl/README.md
Normal 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)
|
||||
272
fluxer_devops/livekitctl/cmd/bootstrap.go
Normal file
272
fluxer_devops/livekitctl/cmd/bootstrap.go
Normal 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))
|
||||
}
|
||||
56
fluxer_devops/livekitctl/cmd/logs.go
Normal file
56
fluxer_devops/livekitctl/cmd/logs.go
Normal 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))
|
||||
}
|
||||
50
fluxer_devops/livekitctl/cmd/restart.go
Normal file
50
fluxer_devops/livekitctl/cmd/restart.go
Normal 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.")
|
||||
}
|
||||
50
fluxer_devops/livekitctl/cmd/root.go
Normal file
50
fluxer_devops/livekitctl/cmd/root.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
fluxer_devops/livekitctl/cmd/status.go
Normal file
46
fluxer_devops/livekitctl/cmd/status.go
Normal 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))
|
||||
}
|
||||
172
fluxer_devops/livekitctl/cmd/webhook.go
Normal file
172
fluxer_devops/livekitctl/cmd/webhook.go
Normal 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)
|
||||
}
|
||||
10
fluxer_devops/livekitctl/go.mod
Normal file
10
fluxer_devops/livekitctl/go.mod
Normal 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
|
||||
)
|
||||
10
fluxer_devops/livekitctl/go.sum
Normal file
10
fluxer_devops/livekitctl/go.sum
Normal 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=
|
||||
433
fluxer_devops/livekitctl/internal/configgen/configgen.go
Normal file
433
fluxer_devops/livekitctl/internal/configgen/configgen.go
Normal 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
|
||||
}
|
||||
53
fluxer_devops/livekitctl/internal/constants/constants.go
Normal file
53
fluxer_devops/livekitctl/internal/constants/constants.go
Normal 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,
|
||||
}
|
||||
}
|
||||
96
fluxer_devops/livekitctl/internal/dnswait/dnswait.go
Normal file
96
fluxer_devops/livekitctl/internal/dnswait/dnswait.go
Normal 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)
|
||||
}
|
||||
}
|
||||
150
fluxer_devops/livekitctl/internal/download/download.go
Normal file
150
fluxer_devops/livekitctl/internal/download/download.go
Normal 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)
|
||||
}
|
||||
66
fluxer_devops/livekitctl/internal/errors/errors.go
Normal file
66
fluxer_devops/livekitctl/internal/errors/errors.go
Normal 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...)}}
|
||||
}
|
||||
95
fluxer_devops/livekitctl/internal/firewall/firewall.go
Normal file
95
fluxer_devops/livekitctl/internal/firewall/firewall.go
Normal 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."
|
||||
}
|
||||
404
fluxer_devops/livekitctl/internal/install/install.go
Normal file
404
fluxer_devops/livekitctl/internal/install/install.go
Normal 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
|
||||
}
|
||||
122
fluxer_devops/livekitctl/internal/netutil/netutil.go
Normal file
122
fluxer_devops/livekitctl/internal/netutil/netutil.go
Normal 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()
|
||||
}
|
||||
307
fluxer_devops/livekitctl/internal/ops/ops.go
Normal file
307
fluxer_devops/livekitctl/internal/ops/ops.go
Normal 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})
|
||||
}
|
||||
}
|
||||
218
fluxer_devops/livekitctl/internal/platform/platform.go
Normal file
218
fluxer_devops/livekitctl/internal/platform/platform.go
Normal 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 ""
|
||||
}
|
||||
91
fluxer_devops/livekitctl/internal/secrets/secrets.go
Normal file
91
fluxer_devops/livekitctl/internal/secrets/secrets.go
Normal 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",
|
||||
}
|
||||
}
|
||||
170
fluxer_devops/livekitctl/internal/state/state.go
Normal file
170
fluxer_devops/livekitctl/internal/state/state.go
Normal 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)
|
||||
}
|
||||
258
fluxer_devops/livekitctl/internal/util/util.go
Normal file
258
fluxer_devops/livekitctl/internal/util/util.go
Normal 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
|
||||
}
|
||||
108
fluxer_devops/livekitctl/internal/validate/validate.go
Normal file
108
fluxer_devops/livekitctl/internal/validate/validate.go
Normal 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
|
||||
}
|
||||
32
fluxer_devops/livekitctl/main.go
Normal file
32
fluxer_devops/livekitctl/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
132
fluxer_devops/livekitctl/scripts/install.sh
Executable file
132
fluxer_devops/livekitctl/scripts/install.sh
Executable 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 "$@"
|
||||
Reference in New Issue
Block a user