package main import ( "crypto/rand" "embed" "flag" "fmt" "io/fs" "log" "math/big" "net" "net/http" "os" "os/signal" "syscall" "context" "github.com/josecarlosrivas/muxdeck/internal/cli" "github.com/josecarlosrivas/muxdeck/internal/cloud" "github.com/josecarlosrivas/muxdeck/internal/mushrun" "github.com/josecarlosrivas/muxdeck/internal/power" "github.com/josecarlosrivas/muxdeck/internal/remote" "github.com/josecarlosrivas/muxdeck/internal/server" "github.com/josecarlosrivas/muxdeck/relay" ) //go:embed web var webFS embed.FS // set via -ldflags "-X main.version=..." in releases var version = "dev" // One binary, two jobs. A bare subcommand selects the CLI; anything else — // no arguments, or the daemon's flags, which all start with "-" — serves. // Shipping the client inside the server is what makes it available wherever // muxdeck already is, which on a box you reached to look at its sessions is // the only place it is any use. func main() { if len(os.Args) > 1 && os.Args[1] == "relay-server" { relayServe(os.Args[2:]) return } if cli.Selected(os.Args[1:]) { cli.Version = version os.Exit(cli.Run(os.Args[1:])) } serve() } // relayServe runs the self-hosted relay: one binary, same as the daemon. // TLS works exactly like the daemon's: bring cert files, or terminate in // front (ACME on your edge). func relayServe(args []string) { fs := flag.NewFlagSet("relay-server", flag.ExitOnError) addr := fs.String("addr", envOr("MUXDECK_RELAY_ADDR", "127.0.0.1:8340"), "listen address") secret := fs.String("secret", os.Getenv("MUXDECK_RELAY_SECRET"), "shared secret the daemon dials with (required)") tlsCert := fs.String("tls-cert", os.Getenv("MUXDECK_RELAY_TLS_CERT"), "TLS certificate file; serve HTTPS when set with -tls-key") tlsKey := fs.String("tls-key", os.Getenv("MUXDECK_RELAY_TLS_KEY"), "TLS key file") fs.Parse(args) if *secret == "" { log.Fatal("relay-server: -secret (or MUXDECK_RELAY_SECRET) is required") } srv := relay.NewServer(*secret) log.Printf("muxdeck relay %s listening on %s", version, *addr) var err error if *tlsCert != "" || *tlsKey != "" { if *tlsCert == "" || *tlsKey == "" { log.Fatal("-tls-cert and -tls-key must be set together") } err = http.ListenAndServeTLS(*addr, *tlsCert, *tlsKey, srv) } else { err = http.ListenAndServe(*addr, srv) } log.Fatal(err) } func serve() { addr := flag.String("addr", envOr("MUXDECK_ADDR", "127.0.0.1:8300"), "listen address") tokenFlag := flag.String("token", os.Getenv("MUXDECK_TOKEN"), `access token; "auto" generates a code; empty generates one on non-loopback binds`) noAuth := flag.Bool("no-auth", os.Getenv("MUXDECK_NO_AUTH") != "", "allow unauthenticated access on non-loopback binds") tlsCert := flag.String("tls-cert", os.Getenv("MUXDECK_TLS_CERT"), "TLS certificate file; serve HTTPS when set with -tls-key") tlsKey := flag.String("tls-key", os.Getenv("MUXDECK_TLS_KEY"), "TLS key file") relayURL := flag.String("relay-url", os.Getenv("MUXDECK_RELAY_URL"), "relay tunnel URL (wss://relay.example/tunnel); session-only override of the relay config") relayKey := flag.String("relay-key", os.Getenv("MUXDECK_RELAY_KEY"), "credential presented to the relay") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() if *showVersion { fmt.Println("muxdeck", version) return } static, err := fs.Sub(webFS, "web") if err != nil { log.Fatal(err) } scheme := "http" if *tlsCert != "" || *tlsKey != "" { if *tlsCert == "" || *tlsKey == "" { log.Fatal("-tls-cert and -tls-key must be set together") } scheme = "https" } remotes, err := remote.Load(envOr("MUXDECK_REMOTES", remote.DefaultPath())) if err != nil { log.Fatal(err) } // ssh tunnels are child processes; kill them on the way out so they // don't outlive the daemon across launchd/sidecar restarts. mushruns := mushrun.New(os.Getenv("MUXDECK_MUSH_BIN")) // The keep-awake assertion is a child too (caffeinate -w binds it to // this pid, but a clean exit should not lean on that). powerm, err := power.Load(envOr("MUXDECK_POWER_CONFIG", power.DefaultConfigPath()), power.Default(), log.Printf) if err != nil { log.Fatal(err) } sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { <-sig powerm.Shutdown() remotes.Shutdown() mushruns.Shutdown() os.Exit(0) }() relaym, err := relay.LoadManager(envOr("MUXDECK_RELAY_CONFIG", relay.DefaultConfigPath())) if err != nil { log.Fatal(err) } if *relayURL != "" { relaym.Override(*relayURL, *relayKey) } // The cloud sync leaves this daemon out of its own sidebar by the name // it was claimed under — the hostname unless relay setup was told // otherwise (MUXDECK_NAME carries that override). self := os.Getenv("MUXDECK_NAME") if self == "" { self, _ = os.Hostname() } cloudm, err := cloud.Load(envOr("MUXDECK_CLOUD_CONFIG", cloud.DefaultConfigPath()), remotes, self) if err != nil { log.Fatal(err) } token, generated := resolveToken(*tokenFlag, *addr, *noAuth) if st := relaym.Status(); st.Configured && !st.Off && !st.Gated && token == "" && !*noAuth { // A configured tunnel publishes this daemon; never come up authless. // Same treatment a non-loopback bind gets: generate an access code. // A gated relay is the exception: it authenticates clients itself, // so the daemon may stay tokenless behind it. token, generated = genCode(8), true } srv := server.New(static, token, generated, remotes, mushruns, relaym, cloudm, powerm) powerm.Start(nil) relaym.Start(srv, token != "", log.Printf) cloudm.Start(context.Background(), log.Printf) if st := cloudm.Status(); st.SignedIn { log.Printf("cloud: signed in at %s; syncing machines", st.URL) } if st := relaym.Status(); st.Configured && !st.Off { log.Printf("relay: dialing %s", st.URL) } log.Printf("muxdeck %s listening on %s", version, *addr) for _, u := range urls(scheme, *addr) { log.Printf(" → %s", u) } switch { case generated: log.Printf("access code: %s (set your own with -token, disable auth with -no-auth)", token) case token != "": log.Printf("auth: token required") default: log.Printf("auth: disabled (loopback bind)") } if scheme == "https" { err = http.ListenAndServeTLS(*addr, *tlsCert, *tlsKey, srv) } else { err = http.ListenAndServe(*addr, srv) } log.Fatal(err) } // Crockford-style alphabet: no I, L, O, U, 0, 1 to avoid misreading. const codeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" func genCode(n int) string { b := make([]byte, n) for i := range b { idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(codeAlphabet)))) if err != nil { log.Fatal(err) } b[i] = codeAlphabet[idx.Int64()] } return string(b) } // resolveToken decides the auth material. Explicit tokens win; otherwise a // short access code is generated whenever the bind is reachable beyond // loopback, unless the operator opts out — secure by default. func resolveToken(token, addr string, noAuth bool) (string, bool) { switch { case token == "auto": return genCode(6), true case token != "": return token, false case noAuth || isLoopback(addr): return "", false default: return genCode(6), true } } func isLoopback(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // urls lists the addresses a client could plausibly reach this server at, // expanding wildcard binds to per-interface URLs. func urls(scheme, addr string) []string { host, port, err := net.SplitHostPort(addr) if err != nil { return []string{fmt.Sprintf("%s://%s", scheme, addr)} } ip := net.ParseIP(host) if host != "" && (ip == nil || !ip.IsUnspecified()) { return []string{fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, port))} } addrs, err := net.InterfaceAddrs() if err != nil { return []string{fmt.Sprintf("%s://localhost:%s", scheme, port)} } var out []string for _, a := range addrs { ipn, ok := a.(*net.IPNet) if !ok || ipn.IP.To4() == nil || ipn.IP.IsLinkLocalUnicast() { continue } out = append(out, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ipn.IP.String(), port))) } return out } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def }