commit 356940fa32f4841f0a10ff3c0579c183d6022c7a Author: James Coleman Date: Sun Nov 28 09:43:26 2021 -0600 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e772ee4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +raid-mount \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..31421ad --- /dev/null +++ b/License.txt @@ -0,0 +1,19 @@ +Copyright (c) 2021 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..443befb --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Raid Mount + +This tool was designed to make it easy to mount encrypted hard drives for a snapraid/mergerfs configuration after a boot of a system. This allows your boot drive to be unencrypted so it can boot without intervention, which you can then finish the boot process via ssh remotely to mount encrypted drives and start services that use them. This does not fully protect your system against physical attack, but it is a compromise I am willing to work with on my system to allow me to finish a boot process if I were to be unable to access the system physically. + +# Raid Mountpoint Table Format + +The format of the raidtab file is similar to the fstab format, but instead of having dump/pass options, there is a CryptName option to specify the name of the device once unencrypted. The CryptName field must be unique per each encrypted drive, and cannot match any existing `/dev/mapper/` device name. If the CryptName is `none`, raid-mount will treat it as an unencrypted mount. There is an example file provided to make this concept easier to understand. + +# Raid Mount Configuration + +Simply create a directory as `/etc/raid-mount/` and place a `config.json` and `raidtab` file within this directory. Configuration options for `config.json` can be viewed in the `config.go` file and the `config.example.json` file. \ No newline at end of file diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..1470796 --- /dev/null +++ b/config.example.json @@ -0,0 +1,8 @@ +{ + "services": [ + "docker", + "nfs-server", + "smb", + "netatalk" + ] +} \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..4c93c01 --- /dev/null +++ b/config.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/user" + "path/filepath" +) + +// Config: Configuration structure. +type Config struct { + RaidTablePath string `json:"raid_table_path"` + Services []string `json:"services"` + + EncryptionKey string `json:"encryption_key"` +} + +// ReadConfig: Read the configuration file. +func (a *App) ReadConfig() { + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Configuration paths. + localConfig, _ := filepath.Abs("./config.json") + homeDirConfig := usr.HomeDir + "/.config/raid-mount/config.json" + etcConfig := "/etc/raid-mount/config.json" + + // Default config. + app.config = Config{ + RaidTablePath: "/etc/raid-mount/raidtab", + } + + // Determine which configuration to use. + var configFile string + if _, err := os.Stat(app.flags.ConfigPath); err == nil && 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 { + log.Println("Unable to find a configuration file.") + return + } + + jsonFile, err := ioutil.ReadFile(configFile) + if err != nil { + fmt.Printf("Error reading JSON file: %s\n", err) + return + } + + err = json.Unmarshal(jsonFile, &app.config) + if err != nil { + fmt.Printf("Error parsing JSON file: %s\n", err) + } +} diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..57b188f --- /dev/null +++ b/flags.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// Flags: Configuration options for cli execution. +type Flags struct { + ConfigPath string + EncryptionKey string + EncryptionPassword string +} + +// Init: Parses configuration options. +func (f *Flags) Init() { + flag.Usage = func() { + fmt.Printf("raid-mount: Mounts raid drives and starts services\n\nUsage:\n") + flag.PrintDefaults() + } + + var printVersion bool + flag.BoolVar(&printVersion, "v", false, "Print version") + + var usage string + usage = "Load configuration from `FILE`" + flag.StringVar(&f.ConfigPath, "config", "", usage) + flag.StringVar(&f.ConfigPath, "c", "", usage+" (shorthand)") + + flag.StringVar(&f.EncryptionKey, "encryption-key", "", "Keyfile to decrypt drives") + usage = "Password to decrypt drives" + flag.StringVar(&f.EncryptionPassword, "encryption-password", "", usage) + flag.StringVar(&f.EncryptionPassword, "p", "", usage+" (shorthand)") + + flag.Parse() + + if printVersion { + fmt.Println("raid-mount: 0.1") + os.Exit(0) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d7f4dc2 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/GRMrGecko/raid-mount + +go 1.17 + +require golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 + +require golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..26b086d --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..84e8eb4 --- /dev/null +++ b/main.go @@ -0,0 +1,284 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" + "syscall" + + "golang.org/x/term" +) + +// RaidMount: Mount point details. +type RaidMount struct { + Source string + Target string + FSType string + Flags string + CryptName string + Encrypted bool +} + +// App: Global application structure. +type App struct { + flags *Flags + config Config +} + +var app *App + +// isMounted: Checks the linux mounts for a target mountpoint to see if it is mounted. +func isMounted(target string) bool { + 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 args[1] == target { + return true + } + } + return false +} + +// main: Starting application function. +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 // If there are encrypted drives, we require a password to decrypt them. + + // 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 5 fields, some formatting is wrong in the table. We will just log/ignore this line. + if len(args) != 5 { + 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, + } + + // 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 mount.CryptName != "none" { + mount.Encrypted = true + hasEncryptedDrives = 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.") + } + } + + // If the encryption password was not provided and an encryption key not provided and there is a mountpoint that is encrypted, + // request the password from the user. + encryptionPassword := app.flags.EncryptionPassword + 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. + for _, mount := range raidMounts { + // 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) + continue + } + + // 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 { + log.Fatalln(err) + } + + // If password was provided, send it to cryptsetup. + if encryptionPassword != "" { + fmt.Fprintln(stdin, encryptionPassword) + } + + // Run cryptsetup to decrypt drive and any error is fatal due to it preventing all required drives from mounting. + err = cmd.Start() + if err != nil { + log.Fatalln(err) + } + + err = cmd.Wait() + 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 { + log.Fatalln("Unable to decrypt:", mount.CryptName) + } + + // Now that its decrypted, update the source path for mounting. + mount.Source = dmPath + } + + // If we're already mounted on this mountpoint, skip to the next one. + if isMounted(mount.Target) { + fmt.Println(mount.Target, "is already mounted") + continue + } + + // Mount the mountpoint. + args := []string{ + "-t", + mount.FSType, + "-o", + mount.Flags, + mount.Source, + mount.Target, + } + + fmt.Println("mount", args) + cmd := exec.Command("mount", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Run mount to mount the mountpoint, any error is fatal as we want to ensure that mountpoints mount. + err = cmd.Start() + if err != nil { + log.Fatalln(err) + } + + err = cmd.Wait() + if err != nil { + log.Fatalln(err) + } + + // Verified that it actually mounted. + if !isMounted(mount.Target) { + log.Fatalln("Unable to mount:", mount.Target) + continue + } + } + + // Now that all mountpoints are mounted, start the services in configuration. + for _, service := range app.config.Services { + // Start the service. + args := []string{ + "start", + service, + } + + fmt.Println("systemctl", args) + cmd := exec.Command("systemctl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start systemctl, any error is not fatal to allow other services to start. + err = cmd.Start() + if err != nil { + log.Println(err) + } + + err = cmd.Wait() + if err != nil { + log.Println(err) + } + } +} diff --git a/raidtab.example b/raidtab.example new file mode 100644 index 0000000..1e7e513 --- /dev/null +++ b/raidtab.example @@ -0,0 +1,6 @@ +# Source Target FSType Flags CryptName +/dev/sdb1 /mnt/sdb1 xfs defaults none +/dev/sdc1 /mnt/sdc1 xfs defaults sdc1 + +# Merged +/mnt/sdb1:/mnt/sdc1 /mnt/merged mergerfs config=/etc/mergerfs.ini,allow_other,use_ino,fsname=merged none \ No newline at end of file