The target path may contain symlinks (e.g. /media/Storage/ -> /mnt/merged/raid/Storage/) but /proc/mounts records the resolved real path. Use filepath.EvalSymlinks and filepath.Clean so the comparison matches regardless of symlinks or trailing slashes.
406 lines
11 KiB
Go
406 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// RaidMount holds mount point details parsed from the raid table.
|
|
type RaidMount struct {
|
|
Source string
|
|
Target string
|
|
FSType string
|
|
Flags string
|
|
CryptName string
|
|
Encrypted bool
|
|
Parallel bool
|
|
}
|
|
|
|
// App is the global application structure.
|
|
type App struct {
|
|
flags *Flags
|
|
config Config
|
|
}
|
|
|
|
var app *App
|
|
|
|
// isMounted checks /proc/mounts for a target mountpoint to determine if it is mounted.
|
|
// Symlinks in the target path are resolved so the comparison matches the real
|
|
// paths recorded by the kernel.
|
|
func isMounted(target string) bool {
|
|
cleanTarget := filepath.Clean(target)
|
|
if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil {
|
|
cleanTarget = resolved
|
|
}
|
|
|
|
file, err := os.Open("/proc/mounts")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
args := strings.Fields(scanner.Text())
|
|
if len(args) < 3 {
|
|
continue
|
|
}
|
|
if filepath.Clean(args[1]) == cleanTarget {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// closeLUKS attempts to close a LUKS volume by name, logging any failure.
|
|
func closeLUKS(cryptName string) {
|
|
cmd := exec.Command("cryptsetup", "close", cryptName)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
log.Printf("Failed to close LUKS volume %s: %v\n", cryptName, err)
|
|
}
|
|
}
|
|
|
|
// mountBindfs handles mounting a bindfs FUSE filesystem.
|
|
//
|
|
// The Flags field uses a "|" separator to distinguish bindfs native flags from
|
|
// FUSE -o options:
|
|
//
|
|
// "resolve-symlinks|allow_other"
|
|
// └─ passed as --resolve-symlinks └─ passed as -o allow_other
|
|
//
|
|
// Either side of the "|" may be empty. If no "|" is present the entire Flags
|
|
// string is treated as bindfs native flags with no -o options.
|
|
func mountBindfs(mount RaidMount) error {
|
|
if isMounted(mount.Target) {
|
|
fmt.Println(mount.Target, "is already mounted")
|
|
return nil
|
|
}
|
|
|
|
// Split flags into bindfs native flags and FUSE -o options.
|
|
var bindfsFlags []string
|
|
var fuseOpts string
|
|
|
|
parts := strings.SplitN(mount.Flags, "|", 2)
|
|
nativeRaw := strings.TrimSpace(parts[0])
|
|
if len(parts) == 2 {
|
|
fuseOpts = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
// Each comma-separated native flag becomes a --flag argument.
|
|
if nativeRaw != "" {
|
|
for _, f := range strings.Split(nativeRaw, ",") {
|
|
f = strings.TrimSpace(f)
|
|
if f != "" {
|
|
bindfsFlags = append(bindfsFlags, "--"+f)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build the full argument list.
|
|
args := bindfsFlags
|
|
if fuseOpts != "" {
|
|
args = append(args, "-o", fuseOpts)
|
|
}
|
|
args = append(args, mount.Source, mount.Target)
|
|
|
|
fmt.Println("bindfs", args)
|
|
cmd := exec.Command("bindfs", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("bindfs %s: %w", mount.Target, err)
|
|
}
|
|
|
|
if !isMounted(mount.Target) {
|
|
return fmt.Errorf("unable to mount: %s", mount.Target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mountDrive decrypts (if needed) and mounts a single drive. Returns an error
|
|
// instead of calling log.Fatal so the caller can coordinate shutdown safely.
|
|
func mountDrive(mount RaidMount, encryptionPassword string) error {
|
|
// Dispatch bindfs mounts to their own handler. bindfs mounts are never
|
|
// encrypted so we skip the cryptsetup path entirely.
|
|
if mount.FSType == "bindfs" {
|
|
return mountBindfs(mount)
|
|
}
|
|
|
|
// Track whether we opened the LUKS volume ourselves so we can clean up on failure.
|
|
openedLUKS := false
|
|
|
|
// If encrypted, decrypt the drive.
|
|
if mount.Encrypted {
|
|
// Check the device path to see if the encrypted drive is already decrypted.
|
|
dmPath := "/dev/mapper/" + mount.CryptName
|
|
if _, err := os.Stat(dmPath); err == nil {
|
|
fmt.Println("Already decrypted:", mount.CryptName)
|
|
} else {
|
|
// Decrypt the drive.
|
|
args := []string{
|
|
"open",
|
|
mount.Source,
|
|
mount.CryptName,
|
|
}
|
|
|
|
// If encryption key file was provided, add argument.
|
|
if app.config.EncryptionKey != "" {
|
|
args = append(args, "--key-file="+app.config.EncryptionKey)
|
|
}
|
|
|
|
fmt.Println("cryptsetup", args)
|
|
cmd := exec.Command("cryptsetup", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("cryptsetup stdin pipe for %s: %w", mount.CryptName, err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("cryptsetup start %s: %w", mount.CryptName, err)
|
|
}
|
|
|
|
// If password was provided, send it to cryptsetup and close stdin
|
|
// so the process receives EOF and does not block.
|
|
if encryptionPassword != "" {
|
|
fmt.Fprintln(stdin, encryptionPassword)
|
|
}
|
|
stdin.Close()
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
return fmt.Errorf("cryptsetup open %s: %w", mount.CryptName, err)
|
|
}
|
|
|
|
// If we cannot verify that it is decrypted, the mount will not work.
|
|
if _, err := os.Stat(dmPath); err != nil {
|
|
return fmt.Errorf("unable to decrypt: %s", mount.CryptName)
|
|
}
|
|
openedLUKS = true
|
|
}
|
|
|
|
// Now that it is decrypted, update the source path for mounting.
|
|
mount.Source = dmPath
|
|
}
|
|
|
|
// If we're already mounted on this mountpoint, skip.
|
|
if isMounted(mount.Target) {
|
|
fmt.Println(mount.Target, "is already mounted")
|
|
return nil
|
|
}
|
|
|
|
// Build mount arguments, only adding -o if flags are non-empty.
|
|
args := []string{"-t", mount.FSType}
|
|
if mount.Flags != "" {
|
|
args = append(args, "-o", mount.Flags)
|
|
}
|
|
args = append(args, mount.Source, mount.Target)
|
|
|
|
fmt.Println("mount", args)
|
|
cmd := exec.Command("mount", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if mount.Encrypted && openedLUKS {
|
|
closeLUKS(mount.CryptName)
|
|
}
|
|
return fmt.Errorf("mount %s: %w", mount.Target, err)
|
|
}
|
|
|
|
// Verify that it actually mounted.
|
|
if !isMounted(mount.Target) {
|
|
if mount.Encrypted && openedLUKS {
|
|
closeLUKS(mount.CryptName)
|
|
}
|
|
return fmt.Errorf("unable to mount: %s", mount.Target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// main is the entry point for the application.
|
|
func main() {
|
|
// Only allow running as root.
|
|
if os.Getuid() != 0 {
|
|
fmt.Println("You must call this program as root.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Read configurations.
|
|
app = new(App)
|
|
app.flags = new(Flags)
|
|
app.flags.Init()
|
|
app.ReadConfig()
|
|
|
|
// The raid table is how we know what to mount, and it must exist to start.
|
|
if _, err := os.Stat(app.config.RaidTablePath); err != nil {
|
|
log.Fatalln("Raid table does not exist.")
|
|
}
|
|
|
|
var raidMounts []RaidMount
|
|
hasEncryptedDrives := false
|
|
|
|
// Open the raid mountpoint table file.
|
|
raidTab, err := os.Open(app.config.RaidTablePath)
|
|
if err != nil {
|
|
log.Fatalln("Unable to open raid table:", err)
|
|
}
|
|
|
|
// Prepare scanners and regular expressions for parsing raid table.
|
|
scanner := bufio.NewScanner(raidTab)
|
|
comment := regexp.MustCompile(`#.*`)
|
|
uuidMatch := regexp.MustCompile(`^UUID=["]*([0-9a-f-]+)["]*$`)
|
|
partuuidMatch := regexp.MustCompile(`^PARTUUID=["]*([0-9a-f-]+)["]*$`)
|
|
|
|
// Each line item, parse the mountpoint.
|
|
for scanner.Scan() {
|
|
// Read line, and clean up comments/parse fields.
|
|
line := scanner.Text()
|
|
line = comment.ReplaceAllString(line, "")
|
|
args := strings.Fields(line)
|
|
|
|
// If line contains no fields, we can ignore it.
|
|
if len(args) == 0 {
|
|
continue
|
|
}
|
|
|
|
// If line is not 6 fields, some formatting is wrong in the table.
|
|
if len(args) != 6 {
|
|
log.Println("Line does not have correct number of arguments:", line)
|
|
continue
|
|
}
|
|
|
|
// Put fields into mountpoint structure.
|
|
mount := RaidMount{
|
|
Source: strings.ReplaceAll(args[0], "\\040", " "),
|
|
Target: strings.ReplaceAll(args[1], "\\040", " "),
|
|
FSType: args[2],
|
|
Flags: args[3],
|
|
CryptName: args[4],
|
|
Encrypted: false,
|
|
Parallel: false,
|
|
}
|
|
|
|
// If the CryptName field is not none, then it is an encrypted drive.
|
|
if mount.CryptName != "none" {
|
|
mount.Encrypted = true
|
|
hasEncryptedDrives = true
|
|
}
|
|
|
|
// Determine if parallel mount.
|
|
if args[5] == "1" {
|
|
mount.Parallel = true
|
|
}
|
|
|
|
// If the source drive is a UUID or PARTUUID, expand to device name.
|
|
if uuidMatch.MatchString(mount.Source) {
|
|
uuid := uuidMatch.FindStringSubmatch(mount.Source)
|
|
mount.Source = "/dev/disk/by-uuid/" + uuid[1]
|
|
} else if partuuidMatch.MatchString(mount.Source) {
|
|
uuid := partuuidMatch.FindStringSubmatch(mount.Source)
|
|
mount.Source = "/dev/disk/by-partuuid/" + uuid[1]
|
|
}
|
|
|
|
raidMounts = append(raidMounts, mount)
|
|
}
|
|
raidTab.Close()
|
|
|
|
// If the encryption key was passed as a flag, override the configuration file.
|
|
if app.flags.EncryptionKey != "" {
|
|
app.config.EncryptionKey = app.flags.EncryptionKey
|
|
}
|
|
|
|
// If the encryption key file is set, we need to verify it actually exists.
|
|
if app.config.EncryptionKey != "" {
|
|
if _, err := os.Stat(app.config.EncryptionKey); err != nil {
|
|
log.Fatalln("Encryption key specified does not exist.")
|
|
}
|
|
}
|
|
|
|
// Resolve the encryption password from flag, environment variable, or interactive prompt.
|
|
encryptionPassword := app.flags.EncryptionPassword
|
|
if encryptionPassword == "" {
|
|
encryptionPassword = os.Getenv("RAID_MOUNT_ENCRYPTION_PASSWORD")
|
|
}
|
|
if encryptionPassword == "" && app.config.EncryptionKey == "" && hasEncryptedDrives {
|
|
fmt.Print("Please enter the encryption password: ")
|
|
|
|
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
|
|
if err != nil {
|
|
log.Fatalln("Unable to read password:", err)
|
|
}
|
|
fmt.Println()
|
|
|
|
encryptionPassword = string(bytePassword)
|
|
}
|
|
|
|
// With each mountpoint, decrypt and mount. Errors are collected so that a
|
|
// single failure does not silently kill goroutines via os.Exit.
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var mountErrors []error
|
|
|
|
for _, mount := range raidMounts {
|
|
// A non-parallel entry acts as a barrier: wait for all prior mounts to
|
|
// complete and abort if any of them failed.
|
|
if !mount.Parallel {
|
|
wg.Wait()
|
|
mu.Lock()
|
|
if len(mountErrors) > 0 {
|
|
for _, e := range mountErrors {
|
|
log.Println(e)
|
|
}
|
|
log.Fatalln("Aborting due to mount errors.")
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(m RaidMount) {
|
|
defer wg.Done()
|
|
if err := mountDrive(m, encryptionPassword); err != nil {
|
|
mu.Lock()
|
|
mountErrors = append(mountErrors, err)
|
|
mu.Unlock()
|
|
}
|
|
}(mount)
|
|
}
|
|
|
|
// Wait for all remaining mounts and check for errors before starting services.
|
|
wg.Wait()
|
|
if len(mountErrors) > 0 {
|
|
for _, e := range mountErrors {
|
|
log.Println(e)
|
|
}
|
|
log.Fatalln("Aborting due to mount errors.")
|
|
}
|
|
|
|
// Now that all mountpoints are mounted, start the configured services.
|
|
for _, service := range app.config.Services {
|
|
args := []string{"start", service}
|
|
|
|
fmt.Println("systemctl", args)
|
|
cmd := exec.Command("systemctl", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
log.Println("Failed to start service", service+":", err)
|
|
}
|
|
}
|
|
}
|