// Command megalodon manages ARK: Survival Evolved servers over RCON, from a web dashboard. package main import ( "bufio" "context" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "git.veillax.com/Veillax/megalodon/internal/ark" "git.veillax.com/Veillax/megalodon/internal/config" "git.veillax.com/Veillax/megalodon/internal/manager" "git.veillax.com/Veillax/megalodon/internal/rcon" "git.veillax.com/Veillax/megalodon/internal/web" ) var version = "dev" const usage = `megalodon manages ARK: Survival Evolved servers over RCON. megalodon serve [-config megalodon.json] run the dashboard and watch the servers megalodon user [-config ...] NAME [ROLE] add a user or change a password (ROLE: viewer, moderator, admin) megalodon token [-config ...] NAME [ROLE] create an API token (shown once) megalodon rcon -addr HOST:PORT -password PW [COMMAND...] run one console command, or open a prompt megalodon version The password for "user" is read from MEGALODON_PASSWORD or, failing that, from a line on stdin. The configuration path can also be set with MEGALODON_CONFIG. ` func main() { log.SetFlags(log.LstdFlags) if len(os.Args) < 2 { fmt.Fprint(os.Stderr, usage) os.Exit(2) } var err error switch os.Args[1] { case "serve": err = serve(os.Args[2:]) case "user": err = userCmd(os.Args[2:]) case "token": err = tokenCmd(os.Args[2:]) case "rcon": err = rconCmd(os.Args[2:]) case "version", "-v", "--version": fmt.Println("megalodon", version) case "help", "-h", "--help": fmt.Print(usage) default: fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage) os.Exit(2) } if err != nil { fmt.Fprintln(os.Stderr, "megalodon:", err) os.Exit(1) } } func configFlag(fs *flag.FlagSet) *string { def := os.Getenv("MEGALODON_CONFIG") if def == "" { def = "megalodon.json" } return fs.String("config", def, "path of the configuration file") } func serve(args []string) error { fs := flag.NewFlagSet("serve", flag.ExitOnError) path := configFlag(fs) listen := fs.String("listen", "", "override the listen address") fs.Parse(args) h, err := config.Load(*path) if err != nil { return err } // First run: make an admin so the dashboard can be signed in to, and show the password once. if len(h.Get().Users) == 0 { pw := config.RandomPassword() hash, err := config.HashPassword(pw) if err != nil { return err } if err := h.Update(func(c *config.Config) error { c.Users = append(c.Users, config.User{Name: "admin", Role: config.Admin, Hash: hash}) return nil }); err != nil { return err } log.Printf("first run: created user \"admin\" with password %s (change it in Settings → Users; written to %s)", pw, *path) } else if !h.Exists() { h.Update(func(*config.Config) error { return nil }) } cfg := h.Get() if *listen != "" { cfg.Listen = *listen } m, err := manager.New(h) if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() m.Start(ctx) srv := &http.Server{ Addr: cfg.Listen, Handler: web.New(h, m, version).Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute, // No write timeout: the live event stream stays open. } errc := make(chan error, 1) go func() { errc <- srv.ListenAndServe() }() log.Printf("megalodon %s watching %d server(s); dashboard on http://%s", version, len(cfg.Servers), cfg.Listen) if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") { log.Print("note: the dashboard is not on a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure") } select { case err := <-errc: stop() m.Wait() return err case <-ctx.Done(): } sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(sctx) m.Wait() return nil } func readPassword() (string, error) { if pw := os.Getenv("MEGALODON_PASSWORD"); pw != "" { return pw, nil } fmt.Fprint(os.Stderr, "password: ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && line == "" { return "", errors.New("no password given") } return strings.TrimRight(line, "\r\n"), nil } func nameAndRole(fs *flag.FlagSet, def config.Role) (string, config.Role, error) { if fs.NArg() < 1 || fs.NArg() > 2 { return "", "", errors.New("expected NAME [ROLE]") } role := def if fs.NArg() == 2 { role = config.Role(fs.Arg(1)) } if role.Rank() == 0 { return "", "", errors.New("ROLE must be viewer, moderator or admin") } return fs.Arg(0), role, nil } func userCmd(args []string) error { fs := flag.NewFlagSet("user", flag.ExitOnError) path := configFlag(fs) fs.Parse(args) name, role, err := nameAndRole(fs, "") if err != nil && fs.NArg() == 1 { role, err = "", nil // changing an existing user's password } if err != nil { return err } pw, err := readPassword() if err != nil { return err } if len(pw) < 8 { return errors.New("the password must be at least 8 characters") } hash, err := config.HashPassword(pw) if err != nil { return err } h, err := config.Load(*path) if err != nil { return err } err = h.Update(func(c *config.Config) error { for k := range c.Users { if c.Users[k].Name == name { c.Users[k].Hash = hash if role != "" { c.Users[k].Role = role } return nil } } if role == "" { return fmt.Errorf("no user %q: give a ROLE to create one", name) } c.Users = append(c.Users, config.User{Name: name, Role: role, Hash: hash}) return nil }) if err == nil { fmt.Println("saved user", name) } return err } func tokenCmd(args []string) error { fs := flag.NewFlagSet("token", flag.ExitOnError) path := configFlag(fs) fs.Parse(args) name, role, err := nameAndRole(fs, config.Viewer) if err != nil { return err } token, hash, err := config.NewToken() if err != nil { return err } h, err := config.Load(*path) if err != nil { return err } err = h.Update(func(c *config.Config) error { c.Tokens = append(c.Tokens, config.Token{Name: name, Role: role, Hash: hash}) return nil }) if err == nil { fmt.Println(token) fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer '") } return err } func rconCmd(args []string) error { fs := flag.NewFlagSet("rcon", flag.ExitOnError) addr := fs.String("addr", "127.0.0.1:27020", "server host:port") pw := fs.String("password", os.Getenv("MEGALODON_RCON_PASSWORD"), "RCON password (or MEGALODON_RCON_PASSWORD)") fs.Parse(args) c := rcon.New(*addr, *pw) defer c.Close() run := func(cmd string) error { out, err := c.Exec(cmd) if err != nil { return err } if !ark.IsEmptyReply(out) { fmt.Println(strings.TrimRight(out, "\r\n\x00")) } return nil } if fs.NArg() > 0 { return run(strings.Join(fs.Args(), " ")) } fmt.Fprintln(os.Stderr, "connected to", *addr, "- type a command, or exit") in := bufio.NewScanner(os.Stdin) for fmt.Fprint(os.Stderr, "> "); in.Scan(); fmt.Fprint(os.Stderr, "> ") { line := strings.TrimSpace(in.Text()) if line == "exit" || line == "quit" { break } if line == "" { continue } if err := run(line); err != nil { fmt.Fprintln(os.Stderr, "error:", err) } } return nil }