Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and running a headless Jellyfin server over SSH: - Setup: install/uninstall Jellyfin, add media drives (single or btrfs RAID pool), import an existing detached btrfs pool non-destructively, firewall, move Jellyfin storage onto a media drive - Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical disk; never offers a disk hosting /, /boot or swap) - Keep-existing-filesystem path previews on-disk content, skips starter folders when content exists, and grants Jellyfin recursive read access - Manage: copy from external drives, mount/eject, rename/delete - Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART Single static binary; installs to /usr/local/bin so sudo can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
2.6 KiB
Go
84 lines
2.6 KiB
Go
// Package firewall opens/closes the Jellyfin LAN ports via UFW if present.
|
|
//
|
|
// We deliberately target UFW only — Omarchy & Arch installs commonly have it.
|
|
// If UFW isn't installed, the helpers return a no-op plan with a clear note
|
|
// in the title so the TUI surfaces "skipped" instead of silently doing nothing.
|
|
package firewall
|
|
|
|
import (
|
|
"os/exec"
|
|
"strings"
|
|
"tuistream/internal/step"
|
|
)
|
|
|
|
const (
|
|
WebPort = "8096/tcp" // HTTP web UI / API
|
|
DiscoveryPort = "7359/udp" // Jellyfin client auto-discovery on the LAN
|
|
)
|
|
|
|
// Available reports whether UFW is installed (otherwise plans return no-ops).
|
|
func Available() bool {
|
|
_, err := exec.LookPath("ufw")
|
|
return err == nil
|
|
}
|
|
|
|
// State is a snapshot of which Jellyfin ports UFW is currently allowing.
|
|
type State struct {
|
|
UFWInstalled bool
|
|
WebOpen bool
|
|
DiscoveryOpen bool
|
|
}
|
|
|
|
func LoadState() State {
|
|
s := State{UFWInstalled: Available()}
|
|
if !s.UFWInstalled {
|
|
return s
|
|
}
|
|
out, err := exec.Command("ufw", "status").Output()
|
|
if err != nil {
|
|
return s
|
|
}
|
|
text := string(out)
|
|
s.WebOpen = containsAllow(text, WebPort)
|
|
s.DiscoveryOpen = containsAllow(text, DiscoveryPort)
|
|
return s
|
|
}
|
|
|
|
// AllOpen returns true if both Jellyfin ports are currently allowed.
|
|
func (s State) AllOpen() bool { return s.WebOpen && s.DiscoveryOpen }
|
|
|
|
// AnyOpen returns true if at least one Jellyfin port is allowed (used to
|
|
// decide whether the toggle's verb should be "close" or "open").
|
|
func (s State) AnyOpen() bool { return s.WebOpen || s.DiscoveryOpen }
|
|
|
|
func containsAllow(ufwStatus, port string) bool {
|
|
// `ufw status` lines look like: "8096/tcp ALLOW Anywhere"
|
|
for _, line := range strings.Split(ufwStatus, "\n") {
|
|
if strings.Contains(line, port) && strings.Contains(line, "ALLOW") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// OpenPlan returns the steps needed to allow Jellyfin's two LAN ports.
|
|
func OpenPlan() []step.Step {
|
|
if !Available() {
|
|
return []step.Step{{Title: "UFW not installed — skipping firewall", Cmd: exec.Command("true")}}
|
|
}
|
|
return []step.Step{
|
|
{Title: "ufw allow " + WebPort, Cmd: exec.Command("ufw", "allow", WebPort)},
|
|
{Title: "ufw allow " + DiscoveryPort, Cmd: exec.Command("ufw", "allow", DiscoveryPort)},
|
|
}
|
|
}
|
|
|
|
// ClosePlan returns the steps needed to remove the two Jellyfin allow rules.
|
|
func ClosePlan() []step.Step {
|
|
if !Available() {
|
|
return []step.Step{{Title: "UFW not installed — nothing to close", Cmd: exec.Command("true")}}
|
|
}
|
|
return []step.Step{
|
|
{Title: "ufw delete allow " + WebPort, Cmd: exec.Command("ufw", "delete", "allow", WebPort)},
|
|
{Title: "ufw delete allow " + DiscoveryPort, Cmd: exec.Command("ufw", "delete", "allow", DiscoveryPort)},
|
|
}
|
|
}
|