Improve goroutine safety, error handling, and correctness

- Replace log.Fatalln in goroutines with error returns collected via
  mutex-protected slice, checked at barrier points and after final wait.
- Fix WaitGroup race between mountDrive and mountBindfs by removing the
  separate Add/Done pair and calling mountBindfs as a plain function.
- Fix encrypted+already-decrypted path skipping the mount step entirely.
- Add LUKS cleanup (cryptsetup close) when mount fails after decryption.
- Close cryptsetup stdin pipe after writing password to prevent hangs.
- Only pass -o to mount when Flags is non-empty.
- Support RAID_MOUNT_ENCRYPTION_PASSWORD env var as alternative to the
  command-line flag which is visible in the process list.
- Replace deprecated ioutil.ReadFile with os.ReadFile.
- Standardize error reporting on log package, remove mixed fmt.Printf.
- Fix config path resolution to check flag emptiness before stat.
- Add parallel semantics documentation to raidtab.example.
This commit is contained in:
GRMrGecko 2026-02-20 12:47:02 -06:00
parent 03d5d70e3c
commit d5a3653915
4 changed files with 228 additions and 126 deletions

View File

@ -2,15 +2,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/ioutil"
"log" "log"
"os" "os"
"os/user" "os/user"
"path/filepath" "path/filepath"
) )
// Config: Configuration structure. // Config holds the application configuration.
type Config struct { type Config struct {
RaidTablePath string `json:"raid_table_path"` RaidTablePath string `json:"raid_table_path"`
Services []string `json:"services"` Services []string `json:"services"`
@ -18,7 +16,7 @@ type Config struct {
EncryptionKey string `json:"encryption_key"` EncryptionKey string `json:"encryption_key"`
} }
// ReadConfig: Read the configuration file. // ReadConfig reads and parses the configuration file.
func (a *App) ReadConfig() { func (a *App) ReadConfig() {
usr, err := user.Current() usr, err := user.Current()
if err != nil { if err != nil {
@ -37,27 +35,33 @@ func (a *App) ReadConfig() {
// Determine which configuration to use. // Determine which configuration to use.
var configFile string var configFile string
if _, err := os.Stat(app.flags.ConfigPath); err == nil && app.flags.ConfigPath != "" { if app.flags.ConfigPath != "" {
if _, err := os.Stat(app.flags.ConfigPath); err != nil {
log.Fatalln("Specified configuration file does not exist:", app.flags.ConfigPath)
}
configFile = app.flags.ConfigPath configFile = app.flags.ConfigPath
} else if _, err := os.Stat(localConfig); err == nil {
configFile = localConfig
} else if _, err := os.Stat(homeDirConfig); err == nil {
configFile = homeDirConfig
} else if _, err := os.Stat(etcConfig); err == nil {
configFile = etcConfig
} else { } else {
// Search standard paths in priority order.
for _, candidate := range []string{localConfig, homeDirConfig, etcConfig} {
if _, err := os.Stat(candidate); err == nil {
configFile = candidate
break
}
}
if configFile == "" {
log.Println("Unable to find a configuration file.") log.Println("Unable to find a configuration file.")
return return
} }
}
jsonFile, err := ioutil.ReadFile(configFile) jsonFile, err := os.ReadFile(configFile)
if err != nil { if err != nil {
fmt.Printf("Error reading JSON file: %s\n", err) log.Printf("Error reading JSON file: %s\n", err)
return return
} }
err = json.Unmarshal(jsonFile, &app.config) err = json.Unmarshal(jsonFile, &app.config)
if err != nil { if err != nil {
fmt.Printf("Error parsing JSON file: %s\n", err) log.Printf("Error parsing JSON file: %s\n", err)
} }
} }

View File

@ -6,14 +6,14 @@ import (
"os" "os"
) )
// Flags: Configuration options for cli execution. // Flags holds configuration options for CLI execution.
type Flags struct { type Flags struct {
ConfigPath string ConfigPath string
EncryptionKey string EncryptionKey string
EncryptionPassword string EncryptionPassword string
} }
// Init: Parses configuration options. // Init parses configuration options from command-line flags.
func (f *Flags) Init() { func (f *Flags) Init() {
flag.Usage = func() { flag.Usage = func() {
fmt.Printf("raid-mount: Mounts raid drives and starts services\n\nUsage:\n") fmt.Printf("raid-mount: Mounts raid drives and starts services\n\nUsage:\n")
@ -29,7 +29,7 @@ func (f *Flags) Init() {
flag.StringVar(&f.ConfigPath, "c", "", usage+" (shorthand)") flag.StringVar(&f.ConfigPath, "c", "", usage+" (shorthand)")
flag.StringVar(&f.EncryptionKey, "encryption-key", "", "Keyfile to decrypt drives") flag.StringVar(&f.EncryptionKey, "encryption-key", "", "Keyfile to decrypt drives")
usage = "Password to decrypt drives" usage = "Password to decrypt drives (visible in process list; prefer RAID_MOUNT_ENCRYPTION_PASSWORD env var)"
flag.StringVar(&f.EncryptionPassword, "encryption-password", "", usage) flag.StringVar(&f.EncryptionPassword, "encryption-password", "", usage)
flag.StringVar(&f.EncryptionPassword, "p", "", usage+" (shorthand)") flag.StringVar(&f.EncryptionPassword, "p", "", usage+" (shorthand)")

236
main.go
View File

@ -14,7 +14,7 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
// RaidMount: Mount point details. // RaidMount holds mount point details parsed from the raid table.
type RaidMount struct { type RaidMount struct {
Source string Source string
Target string Target string
@ -25,7 +25,7 @@ type RaidMount struct {
Parallel bool Parallel bool
} }
// App: Global application structure. // App is the global application structure.
type App struct { type App struct {
flags *Flags flags *Flags
config Config config Config
@ -33,7 +33,7 @@ type App struct {
var app *App var app *App
// isMounted: Checks the linux mounts for a target mountpoint to see if it is mounted. // isMounted checks /proc/mounts for a target mountpoint to determine if it is mounted.
func isMounted(target string) bool { func isMounted(target string) bool {
file, err := os.Open("/proc/mounts") file, err := os.Open("/proc/mounts")
if err != nil { if err != nil {
@ -54,18 +54,93 @@ func isMounted(target string) bool {
return false return false
} }
func mountDrive(mount RaidMount, encryptionPassword string, wg *sync.WaitGroup) { // closeLUKS attempts to close a LUKS volume by name, logging any failure.
// Make sure we tell the wait group that we're done when the mount is done. func closeLUKS(cryptName string) {
defer wg.Done() 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 encrypted, decrypt the drive.
if mount.Encrypted { if mount.Encrypted {
// Check the device path to see if the encrypted drive is already decrypted. // Check the device path to see if the encrypted drive is already decrypted.
dmPath := "/dev/mapper/" + mount.CryptName dmPath := "/dev/mapper/" + mount.CryptName
if _, err := os.Stat(dmPath); err == nil { if _, err := os.Stat(dmPath); err == nil {
fmt.Println("Already decrypted:", mount.CryptName) fmt.Println("Already decrypted:", mount.CryptName)
return } else {
}
// Decrypt the drive. // Decrypt the drive.
args := []string{ args := []string{
"open", "open",
@ -85,73 +160,71 @@ func mountDrive(mount RaidMount, encryptionPassword string, wg *sync.WaitGroup)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
log.Fatalln(err) return fmt.Errorf("cryptsetup stdin pipe for %s: %w", mount.CryptName, err)
} }
// If password was provided, send it to cryptsetup. 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 != "" { if encryptionPassword != "" {
fmt.Fprintln(stdin, encryptionPassword) fmt.Fprintln(stdin, encryptionPassword)
} }
stdin.Close()
// Run cryptsetup to decrypt drive and any error is fatal due to it preventing all required drives from mounting. if err := cmd.Wait(); err != nil {
err = cmd.Start() return fmt.Errorf("cryptsetup open %s: %w", mount.CryptName, err)
if err != nil {
log.Fatalln(err)
} }
err = cmd.Wait() // If we cannot verify that it is decrypted, the mount will not work.
if err != nil {
log.Fatalln(err)
}
// If we cannot verify that its decrypted, then we need to stop as mount won't work.
if _, err := os.Stat(dmPath); err != nil { if _, err := os.Stat(dmPath); err != nil {
log.Fatalln("Unable to decrypt:", mount.CryptName) return fmt.Errorf("unable to decrypt: %s", mount.CryptName)
}
openedLUKS = true
} }
// Now that its decrypted, update the source path for mounting. // Now that it is decrypted, update the source path for mounting.
mount.Source = dmPath mount.Source = dmPath
} }
// If we're already mounted on this mountpoint, skip to the next one. // If we're already mounted on this mountpoint, skip.
if isMounted(mount.Target) { if isMounted(mount.Target) {
fmt.Println(mount.Target, "is already mounted") fmt.Println(mount.Target, "is already mounted")
return return nil
} }
// Mount the mountpoint. // Build mount arguments, only adding -o if flags are non-empty.
args := []string{ args := []string{"-t", mount.FSType}
"-t", if mount.Flags != "" {
mount.FSType, args = append(args, "-o", mount.Flags)
"-o",
mount.Flags,
mount.Source,
mount.Target,
} }
args = append(args, mount.Source, mount.Target)
fmt.Println("mount", args) fmt.Println("mount", args)
cmd := exec.Command("mount", args...) cmd := exec.Command("mount", args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Run mount to mount the mountpoint, any error is fatal as we want to ensure that mountpoints mount. if err := cmd.Run(); err != nil {
err := cmd.Start() if mount.Encrypted && openedLUKS {
if err != nil { closeLUKS(mount.CryptName)
log.Fatalln(err) }
return fmt.Errorf("mount %s: %w", mount.Target, err)
} }
err = cmd.Wait() // Verify that it actually mounted.
if err != nil {
log.Fatalln(err)
}
// Verified that it actually mounted.
if !isMounted(mount.Target) { if !isMounted(mount.Target) {
log.Fatalln("Unable to mount:", mount.Target) if mount.Encrypted && openedLUKS {
closeLUKS(mount.CryptName)
} }
return fmt.Errorf("unable to mount: %s", mount.Target)
}
return nil
} }
// main: Starting application function. // main is the entry point for the application.
func main() { func main() {
// Only allow running as root. // Only allow running as root.
if os.Getuid() != 0 { if os.Getuid() != 0 {
@ -171,7 +244,7 @@ func main() {
} }
var raidMounts []RaidMount var raidMounts []RaidMount
hasEncryptedDrives := false // If there are encrypted drives, we require a password to decrypt them. hasEncryptedDrives := false
// Open the raid mountpoint table file. // Open the raid mountpoint table file.
raidTab, err := os.Open(app.config.RaidTablePath) raidTab, err := os.Open(app.config.RaidTablePath)
@ -197,7 +270,7 @@ func main() {
continue continue
} }
// If line is not 5 fields, some formatting is wrong in the table. We will just log/ignore this line. // If line is not 6 fields, some formatting is wrong in the table.
if len(args) != 6 { if len(args) != 6 {
log.Println("Line does not have correct number of arguments:", line) log.Println("Line does not have correct number of arguments:", line)
continue continue
@ -214,7 +287,7 @@ func main() {
Parallel: false, Parallel: false,
} }
// If the CryptName field is not none, then it is an encrypted drive. We must set the variables for logic below to easily determine if it has encryption. // If the CryptName field is not none, then it is an encrypted drive.
if mount.CryptName != "none" { if mount.CryptName != "none" {
mount.Encrypted = true mount.Encrypted = true
hasEncryptedDrives = true hasEncryptedDrives = true
@ -250,9 +323,11 @@ func main() {
} }
} }
// If the encryption password was not provided and an encryption key not provided and there is a mountpoint that is encrypted, // Resolve the encryption password from flag, environment variable, or interactive prompt.
// request the password from the user.
encryptionPassword := app.flags.EncryptionPassword encryptionPassword := app.flags.EncryptionPassword
if encryptionPassword == "" {
encryptionPassword = os.Getenv("RAID_MOUNT_ENCRYPTION_PASSWORD")
}
if encryptionPassword == "" && app.config.EncryptionKey == "" && hasEncryptedDrives { if encryptionPassword == "" && app.config.EncryptionKey == "" && hasEncryptedDrives {
fmt.Print("Please enter the encryption password: ") fmt.Print("Please enter the encryption password: ")
@ -265,43 +340,58 @@ func main() {
encryptionPassword = string(bytePassword) encryptionPassword = string(bytePassword)
} }
// With each mountpoint, decrypt and mount. // 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 wg sync.WaitGroup
var mu sync.Mutex
var mountErrors []error
for _, mount := range raidMounts { for _, mount := range raidMounts {
// If this task is not parallel, wait for previous tasks to complete before processing. // 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 { if !mount.Parallel {
wg.Wait() wg.Wait()
mu.Lock()
if len(mountErrors) > 0 {
for _, e := range mountErrors {
log.Println(e)
} }
// Add 1 to the wait group as we're spawning a task. log.Fatalln("Aborting due to mount errors.")
wg.Add(1) }
// Mount the drive. mu.Unlock()
go mountDrive(mount, encryptionPassword, &wg)
} }
// Now that all mounts are in progress, we wait before starting services.
wg.Wait()
// Now that all mountpoints are mounted, start the services in configuration. wg.Add(1)
for _, service := range app.config.Services { go func(m RaidMount) {
// Start the service. defer wg.Done()
args := []string{ if err := mountDrive(m, encryptionPassword); err != nil {
"start", mu.Lock()
service, 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) fmt.Println("systemctl", args)
cmd := exec.Command("systemctl", args...) cmd := exec.Command("systemctl", args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Start systemctl, any error is not fatal to allow other services to start. if err := cmd.Run(); err != nil {
err = cmd.Start() log.Println("Failed to start service", service+":", err)
if err != nil {
log.Println(err)
}
err = cmd.Wait()
if err != nil {
log.Println(err)
} }
} }
} }

View File

@ -1,6 +1,14 @@
# Source Target FSType Flags CryptName Parallel # Source Target FSType Flags CryptName Parallel
#
# The Parallel field controls mount ordering. Entries with Parallel=1 are
# launched concurrently with the preceding entries. An entry with Parallel=0
# acts as a barrier: all previously launched mounts must complete before it
# starts. This lets you express dependencies by position — for example,
# individual drives can mount in parallel, then a mergerfs union that depends
# on them uses Parallel=0 to wait.
/dev/sdb1 /mnt/sdb1 xfs defaults none 1 /dev/sdb1 /mnt/sdb1 xfs defaults none 1
/dev/sdc1 /mnt/sdc1 xfs defaults sdc1 1 /dev/sdc1 /mnt/sdc1 xfs defaults sdc1 1
# Merged # Merged — waits for the parallel mounts above to finish before starting.
/mnt/sdb1:/mnt/sdc1 /mnt/merged mergerfs config=/etc/mergerfs.ini,allow_other,use_ino,fsname=merged none 0 /mnt/sdb1:/mnt/sdc1 /mnt/merged mergerfs config=/etc/mergerfs.ini,allow_other,use_ino,fsname=merged none 0