package webui import ( "encoding/json" "errors" "net" "net/http" "net/url" "os" "path/filepath" "slices" "syscall" "time" "albedo/cli/internal/config" ) // Options is what a web listener serves and how it is published: the flags a // running server must match, and what --new-link resumes. A field here is a // flag in newWeb, a line in serveArgs, and a condition in backgroundWeb's // changed-options check. type Options struct { // Port is what the command was asked to serve on (0 picks a free one), // and in a manifest the port it bound. Port int `json:"port"` Allow []string `json:"allow,omitempty"` Local bool `json:"local,omitempty"` // Public is the origin a proxy serves this listener through, in the form // ParseOrigin returns, and empty when it is not behind a proxy. Public string `json:"public,omitempty"` TailnetHTTPS bool `json:"tailnet_https,omitempty"` } // PublicOrigin is the origin a proxy serves this listener through, and nil when // it is not behind a proxy. A recorded origin that no longer parses counts as // none: the links then stay on loopback. func (o Options) PublicOrigin() *url.URL { origin, err := ParseOrigin(o.Public) if err != nil { return nil } return origin } // SameAllow reports whether other allows the same networks. The flags are a // set: order, CIDR or address spelling, and a repeated network do not make a // different server. func (o Options) SameAllow(other Options) bool { return slices.Equal(canonicalNetworks(o.Allow), canonicalNetworks(other.Allow)) } func canonicalNetworks(networks []string) []string { out := make([]string, len(networks)) for i, text := range networks { network, err := ParseNetwork(text) if err != nil { out[i] = text continue } out[i] = network.String() } slices.Sort(out) return slices.Compact(out) } // Manifest describes a running albedo web listener: what it serves, and the // process and addresses a later command needs to find it. type Manifest struct { Options Names []string `json:"names,omitempty"` Addresses []string `json:"addresses,omitempty"` PID int `json:"pid"` URL string `json:"url"` } // ManifestPath returns the shared web listener manifest path. func ManifestPath() string { return filepath.Join(config.HomeDir(), "web.json") } // ReadManifest reads and decodes a web listener manifest. func ReadManifest(path string) (Manifest, error) { data, err := os.ReadFile(path) if err != nil { return Manifest{}, err } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { return Manifest{}, err } return manifest, nil } // WriteManifest atomically replaces path with a private web listener manifest. func WriteManifest(path string, manifest Manifest) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } data, err := json.Marshal(manifest) if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".web.json-") if err != nil { return err } name := tmp.Name() defer os.Remove(name) if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(name, path) } // ManifestAlive reports whether the recorded process exists and listens on its // URL. A server that takes its connection but answers late is busy, not dead. func ManifestAlive(manifest Manifest) bool { if manifest.PID <= 0 || manifest.URL == "" { return false } process, err := os.FindProcess(manifest.PID) if err != nil || process == nil || process.Signal(os.Signal(syscall.Signal(0))) != nil { return false } client := &http.Client{Timeout: 250 * time.Millisecond} response, err := client.Get(manifest.URL) if err != nil { var netErr net.Error return errors.As(err, &netErr) && netErr.Timeout() } response.Body.Close() return true }