180 lines
6.6 KiB
Go
180 lines
6.6 KiB
Go
package main
|
|
|
|
// The unit pair for one share (novox/hq ADR 0263 rule 4, to-be 52 §2): a `.mount` that is never enabled
|
|
// and an `.automount` that is. Nothing is mounted at boot and nothing can fail a boot: the mount is made
|
|
// at first use and released after ten idle minutes, and a mount point is always an autofs mount, so
|
|
// nothing writes into the empty directory under it.
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Marker opens every unit file this module writes. A unit file without it is not the module's, and the
|
|
// module never writes over, stops or removes one.
|
|
const Marker = "# Written by the mesh (module mounts, novox/hq ADR 0263)."
|
|
|
|
// Timing, in seconds.
|
|
const (
|
|
IdleSeconds = 600 // released after ten idle minutes
|
|
MountSeconds = 30 // a share of a node
|
|
OccasionalSeconds = 10 // a source that comes and goes
|
|
)
|
|
|
|
// EscapePath is `systemd-escape --path`: the name systemd gives the units for a mount point.
|
|
func EscapePath(p string) string {
|
|
p = strings.Trim(p, "/")
|
|
if p == "" {
|
|
return "-"
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(p); i++ {
|
|
c := p[i]
|
|
switch {
|
|
case c == '/':
|
|
b.WriteByte('-')
|
|
case c == '.' && i == 0:
|
|
fmt.Fprintf(&b, `\x%02x`, c)
|
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.':
|
|
b.WriteByte(c)
|
|
default:
|
|
fmt.Fprintf(&b, `\x%02x`, c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Pair is one share's two units, as written.
|
|
type Pair struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // nfs, smb or disk
|
|
Occasional bool `json:"occasional"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
What string `json:"what"`
|
|
// Probe is what is tried to tell whether the source is there: host:port, or a device path.
|
|
Probe string `json:"probe"`
|
|
MountUnit string `json:"mount_unit"`
|
|
AutoUnit string `json:"automount_unit"`
|
|
Mount string `json:"-"`
|
|
Automount string `json:"-"`
|
|
// Credentials is the path of the source's credentials file (SMB), written beside the units.
|
|
Credentials string `json:"credentials,omitempty"`
|
|
}
|
|
|
|
// ServerShare is one share as the sharing machine says it exports it (its `node-nfs-server.exports`).
|
|
type ServerShare struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Access string `json:"access"`
|
|
UID int `json:"uid"`
|
|
GID int `json:"gid"`
|
|
Clients string `json:"clients,omitempty"`
|
|
Live bool `json:"live"`
|
|
// To is each node the server exports the share to, at the access it decided for that node.
|
|
To []ShareClient `json:"to,omitempty"`
|
|
}
|
|
|
|
// ShareClient is one node a share is exported to, as the server decided it.
|
|
type ShareClient struct {
|
|
Node string `json:"node"`
|
|
Address string `json:"address"`
|
|
Access string `json:"access"`
|
|
}
|
|
|
|
// ForNode is the share as the server exports it to one node: its access is the lower of the share's own
|
|
// and the node's, and false when the server does not export it to that node at all.
|
|
func (e ServerShare) ForNode(node string) (ServerShare, bool) {
|
|
for _, c := range e.To {
|
|
if c.Node == node {
|
|
if c.Access == "ro" || e.Access == "ro" {
|
|
e.Access = "ro"
|
|
}
|
|
return e, true
|
|
}
|
|
}
|
|
return e, false
|
|
}
|
|
|
|
func header(name, kind string) string {
|
|
return Marker + "\n# The " + kind + " " + name + ". Replaced whenever the module's settings or the sharing machine's\n" +
|
|
"# answer change, and removed when nothing asks for it; edit nothing here: change the module's settings.\n"
|
|
}
|
|
|
|
// NFSPair is the pair for a share of a node: NFS version 4.2 from the sharing machine's address on the
|
|
// private network. Read-write is mounted hard, so a write is never dropped while the server is away;
|
|
// read-only, soft, so a reader gets an error rather than waiting.
|
|
func NFSPair(w Wanted, at string, e ServerShare) Pair {
|
|
ro := w.ReadOnly || e.Access == "ro"
|
|
opts := "vers=4.2,hard,timeo=150,retrans=3,_netdev"
|
|
if ro {
|
|
opts = "ro,vers=4.2,soft,timeo=150,retrans=3,_netdev"
|
|
}
|
|
p := Pair{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, What: at + ":" + e.Path, Probe: hostPort(at, 2049)}
|
|
p.render("share", "nfs4", opts, MountSeconds, "remote-fs.target", "")
|
|
return p
|
|
}
|
|
|
|
// SMBPair is the pair for an occasional SMB source. Its login is in a file only root reads.
|
|
func SMBPair(s Source, credentials string, uid, gid int) Pair {
|
|
opts := "credentials=" + credentials + ",iocharset=utf8,_netdev,nofail"
|
|
if uid >= 0 {
|
|
opts += ",uid=" + strconv.Itoa(uid) + ",gid=" + strconv.Itoa(gid)
|
|
}
|
|
if s.ReadOnly {
|
|
opts = "ro," + opts
|
|
}
|
|
p := Pair{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint,
|
|
What: "//" + s.Host + "/" + s.Share, Probe: hostPort(s.Host, 445), Credentials: credentials}
|
|
p.render("source", "cifs", opts, OccasionalSeconds, "remote-fs.target", "")
|
|
return p
|
|
}
|
|
|
|
// DiskPair is the pair for a disk that is plugged in now and then: bound to its device, so the mount
|
|
// goes when the disk does.
|
|
func DiskPair(s Source) Pair {
|
|
dev := "/dev/disk/by-uuid/" + s.UUID
|
|
opts := "nofail"
|
|
if s.ReadOnly {
|
|
opts = "ro,nofail"
|
|
}
|
|
p := Pair{Name: s.Name, Kind: "disk", Occasional: true, Mountpoint: s.Mountpoint, What: dev, Probe: dev}
|
|
p.render("source", "", opts, OccasionalSeconds, "local-fs.target", EscapePath(dev)+".device")
|
|
return p
|
|
}
|
|
|
|
func hostPort(host string, port int) string {
|
|
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
|
return "[" + host + "]:" + strconv.Itoa(port)
|
|
}
|
|
return host + ":" + strconv.Itoa(port)
|
|
}
|
|
|
|
func (p *Pair) render(kind, fstype, opts string, timeout int, target, device string) {
|
|
base := EscapePath(p.Mountpoint)
|
|
p.MountUnit, p.AutoUnit = base+".mount", base+".automount"
|
|
var m strings.Builder
|
|
m.WriteString(header(p.Name, kind))
|
|
m.WriteString("[Unit]\nDescription=" + p.Name + " (" + p.What + "), mounted by the mesh\n")
|
|
if device != "" {
|
|
m.WriteString("BindsTo=" + device + "\nAfter=" + device + "\n")
|
|
} else {
|
|
m.WriteString("Wants=network-online.target\nAfter=network-online.target\n")
|
|
}
|
|
m.WriteString("\n[Mount]\nWhat=" + p.What + "\nWhere=" + p.Mountpoint + "\n")
|
|
if fstype != "" {
|
|
m.WriteString("Type=" + fstype + "\n")
|
|
}
|
|
m.WriteString("Options=" + opts + "\nTimeoutSec=" + strconv.Itoa(timeout) + "\n")
|
|
p.Mount = m.String()
|
|
|
|
var a strings.Builder
|
|
a.WriteString(header(p.Name, kind))
|
|
// The automount is armed whether or not the source is there; only the mount waits on it, so an
|
|
// absent disk leaves an armed automount, never a failed unit.
|
|
a.WriteString("[Unit]\nDescription=" + p.Name + ", mounted by the mesh at first use\n")
|
|
a.WriteString("\n[Automount]\nWhere=" + p.Mountpoint + "\nTimeoutIdleSec=" + strconv.Itoa(IdleSeconds) + "\n")
|
|
a.WriteString("\n[Install]\nWantedBy=" + target + "\n")
|
|
p.Automount = a.String()
|
|
}
|