Files
lunar-tear/server/cmd/wizard/main.go
T
2026-04-27 15:57:12 +03:00

902 lines
24 KiB
Go

package main
import (
"encoding/json"
"flag"
"fmt"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"charm.land/huh/v2"
"charm.land/huh/v2/spinner"
"charm.land/lipgloss/v2"
)
const banner = `
_ _____
| | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _
| |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_|
|____\_,_|_||_\__,_|_| |_|\___|\__,_|_|
`
const (
configFile = ".wizard.json"
exitVal = "__exit__"
)
type config struct {
IP string `json:"ip"`
Device string `json:"device"`
Detail string `json:"detail"`
Summary string `json:"summary"`
GRPCPort int `json:"grpc_port,omitempty"`
CDNPort int `json:"cdn_port,omitempty"`
AuthPort int `json:"auth_port,omitempty"`
}
const (
defaultGRPCPort = 8003
defaultCDNPort = 8080
defaultAuthPort = 3000
)
type ports struct {
GRPC int
CDN int
Auth int
}
func main() {
setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching")
preferSaved := flag.Bool("prefer-saved", false, "reuse saved config without prompting")
grpcPort := flag.Int("grpc-port", defaultGRPCPort, "gRPC server port")
cdnPort := flag.Int("cdn-port", defaultCDNPort, "CDN server port")
authPort := flag.Int("auth-port", defaultAuthPort, "auth server port")
flag.Parse()
flagSet := map[string]bool{}
flag.Visit(func(f *flag.Flag) { flagSet[f.Name] = true })
lipgloss.EnableLegacyWindowsANSI(os.Stdout)
lipgloss.EnableLegacyWindowsANSI(os.Stderr)
fmt.Print(banner)
if !*setupOnly {
validateAssets()
validateTools()
validateProtocIncludes()
runProtoc()
runMigrate()
downloadDeps()
}
ip, cfg, firstRun := resolveIP(*preferSaved)
p := resolvePorts(flagSet, *grpcPort, *cdnPort, *authPort, cfg)
savedPorts := portsFromConfig(cfg)
if !firstRun && (p.GRPC != savedPorts.GRPC || p.CDN != savedPorts.CDN || p.Auth != savedPorts.Auth) {
if !warnPortChange(savedPorts, p) {
os.Exit(0)
}
}
cfg.GRPCPort = p.GRPC
cfg.CDNPort = p.CDN
cfg.AuthPort = p.Auth
saveConfig(cfg)
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14)
addrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
fmt.Println()
fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.GRPC)))
fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.CDN)))
fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(fmt.Sprintf("%s:%d", ip, p.Auth)))
fmt.Println()
if firstRun || *setupOnly {
showPatcherHint(ip, p, !*setupOnly)
}
if *setupOnly {
return
}
launchDev(ip, p)
}
type assetCheck struct {
path string
dir bool
}
var requiredAssets = []assetCheck{
{"assets", true},
{"assets/release/20240404193219.bin.e", false},
}
// platformAssets holds the per-platform rev-0 layout. At least one full set
// must be present; users may have only Android or only iOS extracted.
var platformAssets = [][]assetCheck{
{
{"assets/revisions/0/android/list.bin", false},
{"assets/revisions/0/android/assetbundle", true},
{"assets/revisions/0/android/resources", true},
},
{
{"assets/revisions/0/ios/list.bin", false},
{"assets/revisions/0/ios/assetbundle", true},
{"assets/revisions/0/ios/resources", true},
},
}
func checkAsset(a assetCheck) (missing string, ok bool) {
info, err := os.Stat(a.path)
if err != nil {
return a.path, false
}
if a.dir && !info.IsDir() {
return a.path + string(filepath.Separator), false
}
if !a.dir && info.IsDir() {
return a.path, false
}
return "", true
}
func validateAssets() {
var missing []string
for _, a := range requiredAssets {
if m, ok := checkAsset(a); !ok {
missing = append(missing, m)
}
}
var platformMissing [][]string
anyPlatformOK := false
for _, group := range platformAssets {
var groupMissing []string
for _, a := range group {
if m, ok := checkAsset(a); !ok {
groupMissing = append(groupMissing, m)
}
}
if len(groupMissing) == 0 {
anyPlatformOK = true
}
platformMissing = append(platformMissing, groupMissing)
}
if !anyPlatformOK {
for _, gm := range platformMissing {
missing = append(missing, gm...)
}
}
if len(missing) == 0 {
return
}
errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
var b strings.Builder
b.WriteString(errStyle.Render(" Required game assets are missing."))
b.WriteString("\n\n")
for _, p := range missing {
b.WriteString(pathStyle.Render(" ✗ " + p))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" At least one of assets/revisions/0/android/ or assets/revisions/0/ios/ must be fully present."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG"))
b.WriteString("\n")
fmt.Fprintln(os.Stderr, b.String())
os.Exit(1)
}
type toolReq struct {
bin string
install string // human-readable hint shown when tool must be installed manually
goInstall string // `go install` package path; non-empty means auto-installable
}
var requiredTools = []toolReq{
{"make", "https://www.gnu.org/software/make/", ""},
{"protoc", "https://protobuf.dev/installation/", ""},
{"protoc-gen-go", "", "google.golang.org/protobuf/cmd/protoc-gen-go@latest"},
{"protoc-gen-go-grpc", "", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"},
{"goose", "", "github.com/pressly/goose/v3/cmd/goose@latest"},
}
var toolPaths = map[string]string{}
// findTool looks for a tool on PATH first, then falls back to the current
// directory (for Windows users who drop .exe files into server/).
func findTool(name string) (string, error) {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
local := name
if runtime.GOOS == "windows" {
local += ".exe"
}
if _, err := os.Stat(local); err == nil {
abs, err := filepath.Abs(local)
if err != nil {
return local, nil
}
return abs, nil
}
return "", fmt.Errorf("%s not found", name)
}
func validateTools() {
var manual []toolReq
var installable []toolReq
for _, t := range requiredTools {
if p, err := findTool(t.bin); err == nil {
toolPaths[t.bin] = p
} else if t.goInstall == "" {
manual = append(manual, t)
} else {
installable = append(installable, t)
}
}
if len(manual) > 0 {
errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
var b strings.Builder
b.WriteString(errStyle.Render(" Required tools are not installed."))
b.WriteString("\n\n")
for _, t := range manual {
b.WriteString(nameStyle.Render(fmt.Sprintf(" ✗ %-22s", t.bin)) + hintStyle.Render(t.install))
b.WriteString("\n")
}
b.WriteString("\n")
fmt.Fprintln(os.Stderr, b.String())
os.Exit(1)
}
for _, t := range installable {
fmt.Printf(" Installing %s...\n", t.bin)
cmd := exec.Command("go", "install", t.goInstall)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, " Failed to install %s: %v\n", t.bin, err)
os.Exit(1)
}
p, err := findTool(t.bin)
if err != nil {
fmt.Fprintf(os.Stderr, " %s installed but not found on PATH — is $(go env GOPATH)/bin in your PATH?\n", t.bin)
os.Exit(1)
}
toolPaths[t.bin] = p
}
}
func validateProtocIncludes() {
if _, err := exec.LookPath("protoc"); err == nil {
return
}
// protoc is local (not on PATH) -- verify well-known types are present.
wkt := filepath.Join("google", "protobuf", "empty.proto")
if _, err := os.Stat(wkt); err == nil {
return
}
errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
var b strings.Builder
b.WriteString(errStyle.Render(" protoc well-known types are missing."))
b.WriteString("\n\n")
b.WriteString(pathStyle.Render(" ✗ " + wkt))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Extract the google/ folder from the protoc release zip into server/."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Download: https://github.com/protocolbuffers/protobuf/releases"))
b.WriteString("\n")
fmt.Fprintln(os.Stderr, b.String())
os.Exit(1)
}
func runProtoc() {
_ = spinner.New().Title(" Running protoc...").Action(func() {
runQuiet(exec.Command(toolPaths["make"], "proto", "PROTOC="+toolPaths["protoc"]), "protoc generation")
}).Run()
}
func runMigrate() {
_ = spinner.New().Title(" Running migrations...").Action(func() {
if err := os.MkdirAll("db", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create db/: %v\n", err)
os.Exit(1)
}
runQuiet(exec.Command(toolPaths["goose"], "-dir", "migrations", "-allow-missing", "sqlite3", "db/game.db", "up"), "database migration")
}).Run()
}
func downloadDeps() {
_ = spinner.New().Title(" Downloading dependencies...").Action(func() {
runQuiet(exec.Command("go", "mod", "download"), "dependency download")
}).Run()
}
func runQuiet(cmd *exec.Cmd, label string) {
var buf strings.Builder
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
fmt.Fprintln(os.Stderr)
fmt.Fprint(os.Stderr, buf.String())
fmt.Fprintf(os.Stderr, "\n %s failed: %v\n", label, err)
os.Exit(1)
}
}
func resolveIP(preferSaved bool) (string, config, bool) {
cfg, err := loadConfig()
if err == nil {
if preferSaved {
if isLANBased(cfg) {
if ip, updated, ok := recheckLANIP(cfg); ok {
return ip, updated, false
}
}
return cfg.IP, cfg, false
}
ip, cfg, done := handleSavedConfig(cfg)
if done {
return ip, cfg, false
}
} else if preferSaved {
fmt.Fprintln(os.Stderr, " --prefer-saved: no saved config found; run without the flag first.")
os.Exit(1)
}
ip, cfg := runWizard()
return ip, cfg, true
}
func handleSavedConfig(cfg config) (string, config, bool) {
reuse := true
err := huh.NewConfirm().
Title(fmt.Sprintf("Use same settings as last time? (%s — %s)", cfg.IP, cfg.Summary)).
Affirmative("Yes").
Negative("No, reconfigure").
Value(&reuse).
Run()
if err != nil {
os.Exit(1)
}
if !reuse {
return "", config{}, false
}
if isLANBased(cfg) {
if ip, updated, ok := recheckLANIP(cfg); ok {
return ip, updated, true
}
return "", config{}, false
}
return cfg.IP, cfg, true
}
func isLANBased(cfg config) bool {
if cfg.Detail == "wifi" {
return true
}
switch cfg.Detail {
case "android-studio", "bluestacks", "genymotion":
return false
}
return cfg.Device == "emulator"
}
func recheckLANIP(cfg config) (string, config, bool) {
current := detectLANIP()
if current == "" || current == cfg.IP {
return cfg.IP, cfg, true
}
var action string
err := huh.NewSelect[string]().
Title(fmt.Sprintf("Your LAN IP changed: %s → %s", cfg.IP, current)).
Options(
huh.NewOption("Use new IP ("+current+")", "update"),
huh.NewOption("Keep saved IP ("+cfg.IP+")", "keep"),
huh.NewOption("Reconfigure from scratch", "reconfig"),
).
Value(&action).
Run()
if err != nil {
os.Exit(1)
}
switch action {
case "update":
warnRepatch(cfg.IP, current, portsFromConfig(cfg))
var ack bool
_ = huh.NewConfirm().
Title("Continue launching the server?").
Affirmative("Yes, start").
Negative("No, exit").
Value(&ack).
Run()
if !ack {
os.Exit(0)
}
cfg.IP = current
return current, cfg, true
case "keep":
return cfg.IP, cfg, true
default:
return "", config{}, false
}
}
func warnPortChange(old, new ports) bool {
warnStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
portLine := func(label string, oldP, newP int) string {
if oldP == newP {
return dimStyle.Render(fmt.Sprintf(" %-7s %d (unchanged)", label+":", oldP))
}
return hlStyle.Render(fmt.Sprintf(" %-7s %d → %d", label+":", oldP, newP))
}
var b strings.Builder
b.WriteString("\n")
b.WriteString(warnStyle.Render(" ⚠ Port configuration changed from last run."))
b.WriteString("\n\n")
b.WriteString(portLine("gRPC", old.GRPC, new.GRPC))
b.WriteString("\n")
b.WriteString(portLine("CDN", old.CDN, new.CDN))
b.WriteString("\n")
b.WriteString(portLine("Auth", old.Auth, new.Auth))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Your APK was patched for the old ports. You may need to re-patch."))
b.WriteString("\n\n")
fmt.Print(b.String())
cont := true
err := huh.NewConfirm().
Title("Continue with new ports?").
Affirmative("Yes, continue").
Negative("No, exit").
Value(&cont).
Run()
if err != nil {
os.Exit(1)
}
return cont
}
func warnRepatch(oldIP, newIP string, p ports) {
warnStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts"
repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL)
var b strings.Builder
b.WriteString("\n")
b.WriteString(warnStyle.Render(" ⚠ Your APK was patched for the old IP."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Re-patch using ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":"))
b.WriteString("\n\n")
b.WriteString(" " + repoLink)
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Update the Configuration cell with the new addresses:"))
b.WriteString("\n\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:%d\"", newIP, p.GRPC)))
b.WriteString("\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:%d\"", newIP, p.CDN)))
b.WriteString("\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:%d\"", newIP, p.Auth)))
b.WriteString("\n\n")
fmt.Print(b.String())
}
func showPatcherHint(ip string, p ports, askLaunch bool) {
headStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts"
discordURL := "https://discord.com/invite/MZAf5aVkJG"
repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL)
discordLink := hlStyle.Hyperlink(discordURL).Render(discordURL)
var b strings.Builder
b.WriteString(headStyle.Render(" Next step: patch your APK"))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" The game client must be patched to connect to your server."))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" Open ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" from the scripts repo in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":"))
b.WriteString("\n\n")
b.WriteString(" " + repoLink)
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Get the APK and master data links from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord:"))
b.WriteString("\n\n")
b.WriteString(" " + discordLink)
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Set these in the notebook's Configuration cell:"))
b.WriteString("\n\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:%d\"", ip, p.GRPC)))
b.WriteString("\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:%d\"", ip, p.CDN)))
b.WriteString("\n")
b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:%d\"", ip, p.Auth)))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" Then run all cells — a patched APK will download automatically."))
b.WriteString("\n\n")
fmt.Print(b.String())
if !askLaunch {
return
}
launch := true
_ = huh.NewConfirm().
Title("Launch the server?").
Affirmative("Yes, start").
Negative("No, exit").
Value(&launch).
Run()
if !launch {
os.Exit(0)
}
}
func runWizard() (string, config) {
var device, emu, conn string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Where is the game running?").
Options(
huh.NewOption("Android Emulator on this PC", "emulator"),
huh.NewOption("Phone / Tablet on the same network", "phone"),
huh.NewOption("Exit", exitVal),
).
Value(&device),
).WithShowHelp(true),
huh.NewGroup(
huh.NewSelect[string]().
Title("Which emulator are you using? ").
Options(
huh.NewOption("Android Studio Emulator", "android-studio"),
huh.NewOption("BlueStacks", "bluestacks"),
huh.NewOption("Genymotion", "genymotion"),
huh.NewOption("Nox / LDPlayer / MEmu", "nox-ld-memu"),
huh.NewOption("Other / Not sure", "other"),
huh.NewOption("Exit", exitVal),
).
Value(&emu),
).WithHideFunc(func() bool { return device != "emulator" }).WithShowHelp(true),
huh.NewGroup(
huh.NewSelect[string]().
Title("How is your phone connected to this PC?").
Options(
huh.NewOption("Same Wi-Fi network", "wifi"),
huh.NewOption("Tailscale / ZeroTier / VPN", "vpn"),
huh.NewOption("Something else / I'll type the IP", "manual"),
huh.NewOption("Exit", exitVal),
).
Value(&conn),
).WithHideFunc(func() bool { return device != "phone" }).WithShowHelp(true),
)
if err := form.Run(); err != nil {
os.Exit(1)
}
if device == exitVal || emu == exitVal || conn == exitVal {
os.Exit(0)
}
return buildResult(device, emu, conn)
}
func buildResult(device, emu, conn string) (string, config) {
if device == "emulator" {
return buildEmulatorResult(emu)
}
return buildPhoneResult(conn)
}
func buildEmulatorResult(emu string) (string, config) {
switch emu {
case "android-studio", "bluestacks":
return "10.0.2.2", config{
IP: "10.0.2.2", Device: "emulator", Detail: emu,
Summary: emu + " emulator",
}
case "genymotion":
return "10.0.3.2", config{
IP: "10.0.3.2", Device: "emulator", Detail: emu,
Summary: "Genymotion emulator",
}
default:
ip := detectAndConfirmIP(detectLANIP(), "emulator (LAN IP)")
return ip, config{
IP: ip, Device: "emulator", Detail: emu,
Summary: emu + " emulator (LAN IP)",
}
}
}
func buildPhoneResult(conn string) (string, config) {
switch conn {
case "wifi":
ip := detectAndConfirmIP(detectLANIP(), "Wi-Fi")
return ip, config{IP: ip, Device: "phone", Detail: "wifi", Summary: "phone (Wi-Fi)"}
case "vpn":
ip := detectAndConfirmIP(detectVPNIP(), "VPN")
return ip, config{IP: ip, Device: "phone", Detail: "vpn", Summary: "phone (VPN)"}
default:
ip := promptIP("")
return ip, config{IP: ip, Device: "phone", Detail: "manual", Summary: "phone (manual IP)"}
}
}
func detectAndConfirmIP(detected, label string) string {
if detected == "" {
fmt.Printf(" Could not auto-detect your %s IP address.\n", label)
return promptIP("")
}
return promptIP(detected)
}
func promptIP(defaultVal string) string {
ip := defaultVal
title := "Enter your PC's IP address"
if defaultVal != "" {
title = fmt.Sprintf("Enter your PC's IP address (detected: %s)", defaultVal)
}
err := huh.NewInput().
Title(title).
Description("Press Enter to accept, or type a different address.").
Validate(func(s string) error {
if net.ParseIP(strings.TrimSpace(s)) == nil {
return fmt.Errorf("not a valid IP address")
}
return nil
}).
Value(&ip).
Run()
if err != nil {
os.Exit(1)
}
return strings.TrimSpace(ip)
}
func detectLANIP() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
skipPrefixes := []string{"docker", "veth", "br-", "virbr", "tailscale", "zt", "tun", "utun"}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
name := strings.ToLower(iface.Name)
skip := false
for _, prefix := range skipPrefixes {
if strings.HasPrefix(name, prefix) {
skip = true
break
}
}
if skip {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
continue
}
return ip.String()
}
}
return ""
}
func detectVPNIP() string {
if path, err := exec.LookPath("tailscale"); err == nil {
out, err := exec.Command(path, "ip", "-4").Output()
if err == nil {
ip := strings.TrimSpace(string(out))
if net.ParseIP(ip) != nil {
return ip
}
}
}
_, tailscaleNet, _ := net.ParseCIDR("100.64.0.0/10")
vpnPrefixes := []string{"tailscale", "zt", "tun", "utun", "wg"}
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
name := strings.ToLower(iface.Name)
isVPN := false
for _, prefix := range vpnPrefixes {
if strings.HasPrefix(name, prefix) {
isVPN = true
break
}
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP.To4()
if ip == nil {
continue
}
if isVPN || tailscaleNet.Contains(ip) {
return ip.String()
}
}
}
return ""
}
func loadConfig() (config, error) {
data, err := os.ReadFile(configFile)
if err != nil {
return config{}, err
}
var cfg config
if err := json.Unmarshal(data, &cfg); err != nil {
return config{}, err
}
if cfg.IP == "" {
return config{}, fmt.Errorf("empty config")
}
return cfg, nil
}
func portsFromConfig(cfg config) ports {
p := ports{GRPC: cfg.GRPCPort, CDN: cfg.CDNPort, Auth: cfg.AuthPort}
if p.GRPC == 0 {
p.GRPC = defaultGRPCPort
}
if p.CDN == 0 {
p.CDN = defaultCDNPort
}
if p.Auth == 0 {
p.Auth = defaultAuthPort
}
return p
}
func resolvePorts(flagSet map[string]bool, grpcFlag, cdnFlag, authFlag int, saved config) ports {
resolve := func(name string, flagVal, savedVal, defaultVal int) int {
if flagSet[name] {
return flagVal
}
if savedVal != 0 {
return savedVal
}
return defaultVal
}
return ports{
GRPC: resolve("grpc-port", grpcFlag, saved.GRPCPort, defaultGRPCPort),
CDN: resolve("cdn-port", cdnFlag, saved.CDNPort, defaultCDNPort),
Auth: resolve("auth-port", authFlag, saved.AuthPort, defaultAuthPort),
}
}
func saveConfig(cfg config) {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return
}
_ = os.WriteFile(configFile, append(data, '\n'), 0644)
}
func launchDev(ip string, p ports) {
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
devBin := filepath.Join("bin", "dev"+ext)
_ = spinner.New().Title(" Building services...").Action(func() {
if err := os.MkdirAll("bin", 0755); err != nil {
fmt.Fprintf(os.Stderr, " Failed to create bin/: %v\n", err)
os.Exit(1)
}
runQuiet(exec.Command("go", "build", "-o", devBin, "./cmd/dev"), "build dev")
}).Run()
cmd := exec.Command(devBin,
"--grpc.listen", fmt.Sprintf("0.0.0.0:%d", p.GRPC),
"--grpc.public-addr", fmt.Sprintf("%s:%d", ip, p.GRPC),
"--cdn.listen", fmt.Sprintf("0.0.0.0:%d", p.CDN),
"--cdn.public-addr", fmt.Sprintf("%s:%d", ip, p.CDN),
"--auth.listen", fmt.Sprintf("0.0.0.0:%d", p.Auth),
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start dev server: %v\n", err)
os.Exit(1)
}
go func() {
<-sigCh
_ = cmd.Process.Signal(os.Interrupt)
}()
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}
}