First commit

This commit is contained in:
James Coleman 2021-11-28 09:43:26 -06:00
commit 356940fa32
10 changed files with 446 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
config.json
raid-mount

19
License.txt Normal file
View File

@ -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.

11
README.md Normal file
View File

@ -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.

8
config.example.json Normal file
View File

@ -0,0 +1,8 @@
{
"services": [
"docker",
"nfs-server",
"smb",
"netatalk"
]
}

63
config.go Normal file
View File

@ -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)
}
}

42
flags.go Normal file
View File

@ -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)
}
}

7
go.mod Normal file
View File

@ -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

4
go.sum Normal file
View File

@ -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=

284
main.go Normal file
View File

@ -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)
}
}
}

6
raidtab.example Normal file
View File

@ -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