261101.4 · 5735ff673a95384397d923bbcec92a411a6328d1
main.go splits by purpose
main.go held argument parsing, endpoint and token resolution and opening a browser, beside the program's start. They move to args.go, endpoint.go and browser.go, and main_test.go's tests go with the code they cover. main.go keeps main, the reporter, detaching and error codes.
Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms. git blame -C -C traces them.
reading declarations
Changes to main.go. All files
@@ -6,343 +6,19 @@ package main import ( "bufio"- "context"- "crypto/rand"- "encoding/base32" "errors" "fmt" "io"- "io/fs"- "net"- "net/netip"- neturl "net/url" "os" "os/exec" "strconv" "strings" "syscall"- "time" ) // readyEnv names the descriptor a detached child reports its start on. var readyEnv = envPrefix + "READY_FD" -// defaultPort is 8080 until main finds that this process may bind port 80.-var defaultPort uint16 = 8080--// capNetBindService is the capability bit that lets a process bind ports-// below 1024.-const capNetBindService = 10--// lowPortOK reports whether this process may bind port 80: it holds-// CAP_NET_BIND_SERVICE, as the Debian package grants, or the machine lets-// every user bind low ports.-func lowPortOK() bool {- if b, err := os.ReadFile("/proc/sys/net/ipv4/ip_unprivileged_port_start"); err == nil {- if n, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && n <= 80 {- return true- }- }- b, err := os.ReadFile("/proc/self/status")- return err == nil && hasCap(string(b), capNetBindService)-}--// hasCap reads the effective capability set from a /proc status file.-func hasCap(status string, bit uint) bool {- for _, line := range strings.Split(status, "\n") {- if v, ok := strings.CutPrefix(line, "CapEff:"); ok {- caps, err := strconv.ParseUint(strings.TrimSpace(v), 16, 64)- return err == nil && caps&(1<<bit) != 0- }- }- return false-}--type config struct {- dir string- addr string- sharing bool- share string // the address --share names; "" takes the default route's- secret bool- token string // the secret path's token, from WWW_SECRET or drawn at random- ep netip.AddrPort- fg bool- noopen bool- persist bool // write a persistent lock, so the tree is served after reboot- debug bool // show the build stamp and the index's times- noBug bool // hide the bug button and refuse reports- cache string // the memory for cached git answers, eg 32M; "" takes defaultCache- disk string // the disk for online repos, eg 4G; "" takes defaultDisk- public bool // serve only online repos, to the open web- help bool- version bool- restart bool- force bool // --restart takes servers of other binaries too- list bool- kill bool- commit string // the commit --commit looks up-}--// usageText is written in docopt notation, except that --share takes its-// HOST optionally, as GNU optional arguments do, which docopt cannot say.-// TestUsageIsDocopt checks it with docopt-ng. A pattern goes on over-// indented lines.-var usageText = strings.NewReplacer("@NAME@", progName, "@ENV@", envPrefix).Replace(`Usage:- @NAME@ [-p] [--foreground] [--no-open] [-a ADDR] [--debug] [--no-bug]- [--cache=SIZE] [--disk=SIZE] [--] [DIR]- @NAME@ [-p] [--foreground] [--no-open] --share[=HOST] [--secret] [--debug]- [--no-bug] [--cache=SIZE] [--disk=SIZE] [--] [DIR]- @NAME@ --restart [-f] [--no-open] [--debug] [--no-bug]- [-a ADDR | --share[=HOST]] [--] [DIR]- @NAME@ -l [-a ADDR | --share[=HOST]] [--] [DIR]- @NAME@ --kill [-a ADDR | --share[=HOST]]- @NAME@ --commit=REV [--] [DIR]- @NAME@ --public --disk=SIZE (-a ADDR | --share[=HOST]) [--debug]- [--cache=SIZE]- @NAME@ -h | --help- @NAME@ -V | --version--Options:- -a ADDR, --addr=ADDR serve on this loopback address, IP or IP:PORT; the- port defaults to 80 when @NAME@ may bind it, else- 8080, and the IP to 127.31.N.1, N = uid - 1000- --share[=HOST] share the tree on the address of the default route,- or on HOST, an address or name beyond loopback with- an optional :PORT, eg 192.168.1.5, 0.0.0.0 or- mate.peer.home; anyone who reaches it reads every- file in the tree that other users could read- --secret serve a shared tree under the token in @ENV@SECRET,- or a random one, so only those holding the link- reach it- --foreground when no server runs on the address yet, serve in the- foreground until Ctrl-C- --no-open print the URL without opening a browser- -p, --persist write a .LOCK instead of a .lock: the tree is served- again after a reboot or a stopped server, by the- next server on its address or by --restart- --debug show what serves debugging: above each page's- header, small and faint, the binary's build time,- the seconds since, its commit and the page's render- time; on the index, each server's build and start- and each lock's time; with --restart, on the new- servers- --no-bug hide the bug button at the foot of each page, which- files a numbered bug in this program, not in the- tree, with the page and the server's state; a share- never shows it; with --restart, on the new servers- --cache=SIZE memory a new server may spend on cached git answers,- eg 64M; 0 caches nothing [default: 32M]- --disk=SIZE disk a new server may fill with online repos, eg 4G;- it drops the repos read longest ago [default: 2G]- --restart replace every server this binary started with one- running it now, serving the same trees, and start- one for its persistent trees that have none; with an- address from -a or --share, only the server there,- whichever binary runs it; with DIR, serve it too and- open it unless a page of it was open- -f, --force with --restart, take the servers and persistent- trees of other binaries too, eg the package's- -l, --list print each served tree's directory, URL and the- binary that serves it as keyval; with DIR, only the- trees at or below it- --kill stop every server and print the trees it served,- as with --list; with an address from -a or --share,- only the server there- --commit=REV print as keyval the commit REV names in DIR's repo:- its full hash, its stardate and its subject; REV is- a stardate, eg 261024.4, a hash or any name git- takes- --public serve only repos fetched from a forge, never a tree- here, in the foreground, to anyone who reaches the- address, eg through a proxy on -a; it lists no- cache, shows no stop or bug button and opens nothing- -V, --version print when the binary was built and its commit- -h, --help print this help-`)--// switches maps each long boolean option to its field; shorts names the-// ones with a short form.-func (c *config) switches() map[string]*bool {- return map[string]*bool{- "foreground": &c.fg,- "no-open": &c.noopen,- "persist": &c.persist,- "debug": &c.debug,- "no-bug": &c.noBug,- "help": &c.help,- "version": &c.version,- "restart": &c.restart,- "force": &c.force,- "list": &c.list,- "kill": &c.kill,- "public": &c.public,- }-}--// -f and -n are reserved for --force and --dry-run in every tool of the-// user's, so --foreground and --no-open have no short form.-var shorts = map[byte]string{'p': "persist", 'h': "help", 'V': "version", 'l': "list", 'f': "force"}--// parseArgs reads options the GNU way. Short ones combine, as in -pl, and-// take a value joined or apart: -aIP or -a IP. Long ones take --addr IP or-// --addr=IP. Options may follow the directory, and "--" ends them.-func parseArgs(args []string) (config, error) {- var c config- sw := c.switches()- var dirs []string- value := func(i *int, opt, joined string) (string, error) {- if joined != "" {- return joined, nil- }- if *i+1 == len(args) {- return "", &errno{syscall.EINVAL, "option " + opt + " needs a value"}- }- *i++- return args[*i], nil- }- for i := 0; i < len(args); i++ {- a := args[i]- switch {- case a == "--":- dirs = append(dirs, args[i+1:]...)- i = len(args)- case strings.HasPrefix(a, "--"):- name, val, hasVal := strings.Cut(a[2:], "=")- if name == "addr" {- if hasVal && val == "" {- return c, &errno{syscall.EINVAL, "option --addr needs a value"}- }- v, err := value(&i, "--addr", val)- if err != nil {- return c, err- }- c.addr = v- continue- }- // --share, like --secret, takes its address only joined; alone- // it takes the default route's.- if name == "share" {- if hasVal && val == "" {- return c, &errno{syscall.EINVAL, "option --share= needs an address"}- }- c.sharing, c.share = true, val- continue- }- // --secret takes no token: one on the command line shows in- // ps to every local user and stays in shell history.- if name == "secret" {- if hasVal {- return c, &errno{syscall.EINVAL, "--secret takes no token, since ps shows it; set " + secretEnv}- }- c.secret = true- continue- }- if name == "commit" {- v, err := value(&i, "--commit", val)- if err != nil {- return c, err- }- if v == "" {- return c, &errno{syscall.EINVAL, "option --commit needs a value"}- }- c.commit = v- continue- }- if name == "cache" || name == "disk" {- v, err := value(&i, "--"+name, val)- if err != nil {- return c, err- }- if _, err := parseSize(v); err != nil {- return c, err- }- if name == "cache" {- c.cache = v- } else {- c.disk = v- }- continue- }- p, ok := sw[name]- switch {- case !ok:- return c, &errno{syscall.EINVAL, "unknown option --" + name}- case hasVal:- return c, &errno{syscall.EINVAL, "option --" + name + " takes no value"}- }- *p = true- case len(a) > 1 && a[0] == '-':- for j := 1; j < len(a); j++ {- if a[j] == 'a' {- v, err := value(&i, "-a", a[j+1:])- if err != nil {- return c, err- }- c.addr = v- break- }- name, ok := shorts[a[j]]- if !ok {- return c, &errno{syscall.EINVAL, "unknown option -" + string(a[j])}- }- *sw[name] = true- }- default:- dirs = append(dirs, a)- }- }- if c.commit != "" && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.debug || c.noBug ||- c.sharing || c.secret || c.addr != "" || c.cache != "" || c.disk != "" || c.public) {- return c, &errno{syscall.EINVAL, "--commit takes a directory and no other option"}- }- // A public server runs under a service manager, so it stays in the- // foreground, opens nothing and files no bugs; it serves no tree.- if c.public && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.noBug || c.secret || len(dirs) > 0) {- return c, &errno{syscall.EINVAL, "--public takes an address, --disk, --cache and --debug, and no directory"}- }- if c.public && c.disk == "" {- return c, &errno{syscall.EINVAL, "--public needs --disk, since any visitor can make it clone"}- }- // The default address is the user's own, where a reader's server runs.- if c.public && c.addr == "" && !c.sharing {- return c, &errno{syscall.EINVAL, "--public needs -a or --share"}- }- if c.force && !c.restart {- return c, &errno{syscall.EINVAL, "--force goes with --restart"}- }- if c.restart && c.fg {- return c, &errno{syscall.EINVAL, "--restart takes no --foreground"}- }- if c.persist && (c.restart || c.list || c.kill) {- return c, &errno{syscall.EINVAL, "--persist goes with a start, not with --restart, --list or --kill"}- }- if c.list && (c.restart || c.fg) {- return c, &errno{syscall.EINVAL, "--list takes no --restart or --foreground"}- }- if c.kill && (len(dirs) > 0 || c.fg || c.restart || c.list) {- return c, &errno{syscall.EINVAL, "--kill takes no directory, --foreground, --restart or --list"}- }- if c.addr != "" && c.sharing {- return c, &errno{syscall.EINVAL, "-a and --share name one address each; give one"}- }- if c.secret && (!c.sharing || c.restart || c.list || c.kill) {- return c, &errno{syscall.EINVAL, "--secret goes with --share, and not with --restart, --list or --kill"}- }- switch len(dirs) {- case 0:- // --list without a directory lists every tree, and --restart- // without one serves no tree beyond those it restarts.- if !c.list && !c.kill && !c.restart && !c.public {- c.dir = "."- }- case 1:- c.dir = dirs[0]- default:- return c, &errno{syscall.EINVAL, "one directory at most, got " + strings.Join(dirs, " ")}- }- return c, nil-}- func main() { c, err := parseArgs(os.Args[1:]) switch {@@ -416,229 +92,6 @@ func noteRun() { } } -// Environment variables that set the default addresses, the command that-// opens a loopback URL, and a share's chosen token.-var (- addrEnv = envPrefix + "ADDR"- shareEnv = envPrefix + "SHARE"- openEnv = envPrefix + "OPEN"- secretEnv = envPrefix + "SECRET"-)--// secretToken returns the token in WWW_SECRET, or a random one when it is-// unset. A bad token's error names the variable but not its value, which-// is a secret.-func secretToken() (string, error) {- v := os.Getenv(secretEnv)- switch {- case v == "":- return newToken(), nil- case !validToken(v):- return "", &errno{syscall.EINVAL, secretEnv + " holds a bad token: want letters, digits, - and _"}- }- return v, nil-}--// endpointOf returns where c serves. A bare --share takes WWW_SHARE,-// and a start without -a takes WWW_ADDR; --list, --kill and --restart-// without an address cover every server, so WWW_ADDR leaves them be.-// An error from a variable's value names the variable.-func endpointOf(c config, uid int) (netip.AddrPort, error) {- if c.sharing {- if c.share == "" && os.Getenv(shareEnv) != "" {- ep, err := shareEndpoint(os.Getenv(shareEnv))- return ep, fromEnv(shareEnv, err)- }- return shareEndpoint(c.share)- }- if c.addr == "" && !c.list && !c.kill && !c.restart && os.Getenv(addrEnv) != "" {- ep, err := endpointFor(os.Getenv(addrEnv), uid)- return ep, fromEnv(addrEnv, err)- }- return endpointFor(c.addr, uid)-}--func fromEnv(name string, err error) error {- if err == nil {- return nil- }- return &errno{errnoOf(err), name + "=" + os.Getenv(name) + ": " + err.Error()}-}--// environment is the help's account of the default addresses: each value-// and where it comes from.-func environment(uid int) string {- addr := "built in"- if ep, err := endpointFor("", uid); err == nil {- addr = ep.String() + ", built in"- }- if v := os.Getenv(addrEnv); v != "" {- addr = v + ", from " + addrEnv- }- share := "the default route's address"- if a, err := routeAddr(); err == nil {- share = a.String() + ", the default route's address"- }- if v := os.Getenv(shareEnv); v != "" {- share = v + ", from " + shareEnv- }- open := "xdg-open, built in"- if v := os.Getenv(openEnv); v != "" {- open = v + ", from " + openEnv- }- secret := "unset, so a random token"- if os.Getenv(secretEnv) != "" {- secret = "set"- }- return "\nenvironment:\n" +- " " + addrEnv + " default for a start without -a; now " + addr + "\n" +- " " + shareEnv + " default for a bare --share; now " + share + "\n" +- " " + openEnv + " command that opens a loopback URL, %s for the URL or\n" +- " appended, eg 'brave-browser --app=%s'; now " + open + "\n" +- " " + secretEnv + " the token for --secret; now " + secret + "\n"-}--// endpointFor returns where to serve: addr when given, else the user's own-// 127.31.N.1 with N = uid - 1000, so users on one machine never collide.-// The port is defaultPort unless addr names one. -a takes only loopback, so-// a typo cannot share a tree; --share goes through shareEndpoint.-func endpointFor(addr string, uid int) (netip.AddrPort, error) {- if addr == "" {- n := uid - 1000- if n < 0 || n > 255 {- return netip.AddrPort{}, &errno{syscall.EINVAL, fmt.Sprintf("uid %d has no default address; pass --addr", uid)}- }- return netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 31, byte(n), 1}), defaultPort), nil- }- ep, err := parseEndpoint(addr, "127.31.1.2")- if err != nil {- return ep, err- }- if !ep.Addr().IsLoopback() {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": -a takes a loopback address; --share takes the others"}- }- return ep, nil-}--// shareEndpoint returns where to share: any IPv4 address beyond loopback,-// 0.0.0.0 included, on defaultPort unless addr names one. With no addr it-// is the default route's address, so a share needs no address remembered.-func shareEndpoint(addr string) (netip.AddrPort, error) {- if addr == "" {- a, err := routeAddr()- if err != nil {- return netip.AddrPort{}, err- }- return netip.AddrPortFrom(a, defaultPort), nil- }- ep, err := parseEndpoint(addr, "192.168.1.5")- if err != nil {- if ep, err = resolveEndpoint(addr); err != nil {- return ep, err- }- }- if ep.Addr().IsLoopback() {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": --share takes an address beyond loopback; -a takes loopback"}- }- return ep, nil-}--// resolveEndpoint reads name[:port] and resolves the name to an IPv4-// address that an interface here holds; only such an address can be bound.-func resolveEndpoint(addr string) (netip.AddrPort, error) {- name, port := addr, defaultPort- if h, p, err := net.SplitHostPort(addr); err == nil {- n, err := strconv.ParseUint(p, 10, 16)- if err != nil || n == 0 {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad port in " + addr + ": want 1 to 65535"}- }- name, port = h, uint16(n)- }- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)- defer cancel()- ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", name)- if err != nil {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address or a name, eg 192.168.1.5 or mate.peer.home (" + err.Error() + ")"}- }- for _, ip := range ips {- if ifaceOf(ip.Unmap()) != "any" {- return netip.AddrPortFrom(ip.Unmap(), port), nil- }- }- return netip.AddrPort{}, &errno{syscall.EADDRNOTAVAIL, fmt.Sprintf("%s resolves to %v, which no interface here holds", name, ips)}-}--// spacedShare catches --share 192.168.1.5, where the address became the-// directory: a bare --share with a directory that does not exist but reads-// or resolves as an address. It returns the hint, or "".-func spacedShare(c config) string {- if !c.sharing || c.share != "" || c.dir == "" {- return ""- }- if _, err := os.Stat(c.dir); !errors.Is(err, fs.ErrNotExist) {- return ""- }- if _, err := parseEndpoint(c.dir, ""); err != nil {- if _, err := resolveEndpoint(c.dir); errors.Is(err, syscall.EINVAL) {- return ""- }- }- return "no directory " + c.dir + "; to share on that address, write --share=" + c.dir-}--// routeAddr asks the kernel which source address it would use to reach-// beyond this machine. Connecting a UDP socket picks the route and sends-// nothing. 192.0.2.1 is reserved for documentation, so only the default-// route leads there.-func routeAddr() (netip.Addr, error) {- conn, err := net.Dial("udp4", "192.0.2.1:9")- if err != nil {- return netip.Addr{}, &errno{errnoOf(err), "no default route to share on (" + err.Error() + "); name an address, eg --share=192.168.1.5"}- }- defer conn.Close()- a := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr().Unmap()- if a.IsLoopback() {- return netip.Addr{}, &errno{syscall.ENETUNREACH, "the default route leaves from loopback; name an address, eg --share=192.168.1.5"}- }- return a, nil-}--// parseEndpoint reads ip[:port] as an IPv4 address and a port above 0.-func parseEndpoint(addr, example string) (netip.AddrPort, error) {- ep, err := netip.ParseAddrPort(addr)- if err != nil {- a, aerr := netip.ParseAddr(addr)- if aerr != nil {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want eg " + example + " or " + example + ":9000"}- }- ep = netip.AddrPortFrom(a, defaultPort)- }- if !ep.Addr().Is4() || ep.Port() == 0 {- return netip.AddrPort{}, &errno{syscall.EINVAL, "bad address " + addr + ": want an IPv4 address and a port above 0"}- }- return ep, nil-}--// shared reports whether ep serves beyond this machine.-func shared(ep netip.AddrPort) bool {- return !ep.Addr().IsLoopback()-}--// newToken draws 128 bits for a secret path, in lower-case base32 so the-// link survives being read aloud or typed.-func newToken() string {- b := make([]byte, 16)- rand.Read(b)- return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))-}--// validToken keeps a token to one URL path segment that needs no escaping.-func validToken(s string) bool {- return s != "" && !strings.ContainsFunc(s, func(c rune) bool {- return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')- })-}- // errno is an error with the code www exits with. type errno struct { code syscall.Errno@@ -693,53 +146,6 @@ func show(kind, text string, noopen bool) { } } -// openBrowser opens url when a display exists. Over SSH there is none, and-// the printed URL has to do.-func openBrowser(url string) {- if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {- return- }- args := openArgs(url, os.Getenv(openEnv))- cmd := exec.Command(args[0], args[1:]...)- if err := cmd.Start(); err != nil {- fmt.Fprintf(os.Stderr, "E%d: cannot open %s with %s: %v\n", errnoOf(err), url, args[0], err)- return- }- go cmd.Wait()-}--// openArgs is the command that opens url. A loopback URL takes WWW_OPEN,-// split at spaces, with each %s replaced by the URL, or the URL appended-// when none holds %s; eg 'brave-browser --app=%s' opens an app window. A-// shared URL, and any URL without the variable, goes to xdg-open.-func openArgs(url, open string) []string {- fields := strings.Fields(open)- if len(fields) == 0 || !loopbackURL(url) {- return []string{"xdg-open", url}- }- placed := false- for i, f := range fields {- if strings.Contains(f, "%s") {- fields[i] = strings.ReplaceAll(f, "%s", url)- placed = true- }- }- if !placed {- fields = append(fields, url)- }- return fields-}--// loopbackURL reports whether u names a loopback host.-func loopbackURL(u string) bool {- p, err := neturl.Parse(u)- if err != nil {- return false- }- a, err := netip.ParseAddr(p.Hostname())- return err == nil && a.IsLoopback() || p.Hostname() == "localhost"-}- // detach re-runs www in a new session and relays its start report. // Go cannot fork safely, so a re-exec is the way to background itself. func detach(c config) int {