client flow
This commit is contained in:
@@ -24,6 +24,7 @@ The program:
|
|||||||
- Prompts to add, edit, or delete a share.
|
- Prompts to add, edit, or delete a share.
|
||||||
- Includes a user-management menu to verify all share accounts exist and to remove obviously unused share-style accounts.
|
- Includes a user-management menu to verify all share accounts exist and to remove obviously unused share-style accounts.
|
||||||
- Includes a user-management menu to change a Samba user's password.
|
- Includes a user-management menu to change a Samba user's password.
|
||||||
|
- Includes a client-mount workflow for connecting this computer to a share on another server with `cifs-utils`.
|
||||||
- Checks `valid users` entries against local accounts.
|
- Checks `valid users` entries against local accounts.
|
||||||
- Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin <user>`.
|
- Offers to create missing local accounts with `useradd -M -s /usr/sbin/nologin <user>`.
|
||||||
- If user creation fails because admin rights are needed, explains the issue and offers to retry with `sudo`.
|
- If user creation fails because admin rights are needed, explains the issue and offers to retry with `sudo`.
|
||||||
@@ -32,6 +33,7 @@ The program:
|
|||||||
- Before saving, offers to adjust share-folder ownership and permissions so connected users can actually read and write files.
|
- Before saving, offers to adjust share-folder ownership and permissions so connected users can actually read and write files.
|
||||||
- If saving the Samba config or its backup needs admin rights, explains the issue and offers to retry with `sudo`.
|
- If saving the Samba config or its backup needs admin rights, explains the issue and offers to retry with `sudo`.
|
||||||
- Writes a timestamped backup before saving changes.
|
- Writes a timestamped backup before saving changes.
|
||||||
|
- Can append a CIFS mount entry to `/etc/fstab`, including an inline username and password if that is the setup you want.
|
||||||
|
|
||||||
If you create a local account for a Samba-authenticated share, you may still need to add the Samba password separately:
|
If you create a local account for a Samba-authenticated share, you may still need to add the Samba password separately:
|
||||||
|
|
||||||
|
|||||||
310
app.go
310
app.go
@@ -60,6 +60,21 @@ type PasswdEntry struct {
|
|||||||
Shell string
|
Shell string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CIFSMountConfig struct {
|
||||||
|
Server string
|
||||||
|
Share string
|
||||||
|
MountPoint string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Domain string
|
||||||
|
UID string
|
||||||
|
GID string
|
||||||
|
FileMode string
|
||||||
|
DirMode string
|
||||||
|
AutoMount bool
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
|
func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath LookPathFunc) *App {
|
||||||
return &App{
|
return &App{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
@@ -103,6 +118,7 @@ loaded:
|
|||||||
a.println("[a] add share")
|
a.println("[a] add share")
|
||||||
a.println("[e] edit share")
|
a.println("[e] edit share")
|
||||||
a.println("[d] delete share")
|
a.println("[d] delete share")
|
||||||
|
a.println("[m] set up client mount")
|
||||||
a.println("[u] manage users")
|
a.println("[u] manage users")
|
||||||
a.println("[w] write config and exit")
|
a.println("[w] write config and exit")
|
||||||
a.println("[q] quit without saving")
|
a.println("[q] quit without saving")
|
||||||
@@ -126,6 +142,10 @@ loaded:
|
|||||||
if err := a.deleteShare(doc); err != nil {
|
if err := a.deleteShare(doc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case "m", "mount":
|
||||||
|
if err := a.setupClientMount(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
case "u", "users":
|
case "u", "users":
|
||||||
if err := a.manageUsers(doc); err != nil {
|
if err := a.manageUsers(doc); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -140,6 +160,63 @@ loaded:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) setupClientMount() error {
|
||||||
|
a.println("")
|
||||||
|
a.println("Client mount setup")
|
||||||
|
a.println("==================")
|
||||||
|
a.println("This will help connect this computer to a shared folder on another server.")
|
||||||
|
a.flush()
|
||||||
|
|
||||||
|
if err := a.ensureCIFSUtilsInstalled(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := a.collectCIFSMountConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.ensureMountPoint(cfg.MountPoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
line := buildFstabLine(cfg)
|
||||||
|
a.println("")
|
||||||
|
a.println("Suggested /etc/fstab line:")
|
||||||
|
a.println(line)
|
||||||
|
a.println("")
|
||||||
|
a.println("This will store the username and password directly in /etc/fstab.")
|
||||||
|
a.println("That is simple for home use, but less secure than a credentials file.")
|
||||||
|
a.flush()
|
||||||
|
|
||||||
|
addLine, err := a.confirm("Add this line to /etc/fstab now", true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !addLine {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.appendFstabLine(line); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.println("")
|
||||||
|
a.println("The mount entry was added to /etc/fstab.")
|
||||||
|
mountNow, err := a.confirm("Try mounting it now", true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mountNow {
|
||||||
|
if err := a.mountCIFS(cfg.MountPoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.println("The network share was mounted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) manageUsers(doc *Document) error {
|
func (a *App) manageUsers(doc *Document) error {
|
||||||
for {
|
for {
|
||||||
a.println("")
|
a.println("")
|
||||||
@@ -471,6 +548,192 @@ func (a *App) writeConfig(doc *Document) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureCIFSUtilsInstalled() error {
|
||||||
|
if a.lookPath != nil {
|
||||||
|
if _, err := a.lookPath("mount.cifs"); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.println("")
|
||||||
|
a.println("This computer does not seem to have the CIFS mount tools installed yet.")
|
||||||
|
|
||||||
|
plan, ok := DetectCIFSUtilsInstallPlan(a.lookPath, os.Geteuid() == 0)
|
||||||
|
if !ok {
|
||||||
|
a.println("I couldn't find a supported package manager automatically.")
|
||||||
|
a.println("Install cifs-utils for your Linux distribution, then try again.")
|
||||||
|
return errors.New("cifs-utils is not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.printf("I found %s and can try to install the CIFS client tools for you.\n", plan.ManagerName)
|
||||||
|
a.printf("This would run: %s\n", plan.DisplayCommand())
|
||||||
|
a.flush()
|
||||||
|
|
||||||
|
install, err := a.confirm("Install the CIFS client tools now", true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !install {
|
||||||
|
return errors.New("cifs-utils is required for client mounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.runner.Run(plan.Command[0], plan.Command[1:]...); err != nil {
|
||||||
|
return fmt.Errorf("install CIFS client tools with %s: %w", plan.ManagerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.lookPath != nil {
|
||||||
|
if _, err := a.lookPath("mount.cifs"); err != nil {
|
||||||
|
return fmt.Errorf("cifs-utils installation finished, but mount.cifs is still not available: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) collectCIFSMountConfig() (CIFSMountConfig, error) {
|
||||||
|
server, err := a.prompt("Server name or IP")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
share, err := a.prompt("Share name on that server")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
mountPointDefault := filepath.Join("/mnt", strings.TrimSpace(share))
|
||||||
|
mountPoint, err := a.promptDefault("Local mount folder", mountPointDefault)
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
username, err := a.prompt("Username for the remote share")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
password, err := a.prompt("Password for the remote share")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
domain, err := a.promptDefault("Domain or workgroup (optional)", "")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
uid, err := a.promptDefault("Local owner username or uid (optional)", "")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
gid, err := a.promptDefault("Local group name or gid (optional)", "")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
fileMode, err := a.promptDefault("File permissions", "0664")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
dirMode, err := a.promptDefault("Folder permissions", "0775")
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
autoMount, err := a.confirm("Mount automatically at startup", true)
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
readOnly, err := a.confirm("Make this mount read-only", false)
|
||||||
|
if err != nil {
|
||||||
|
return CIFSMountConfig{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return CIFSMountConfig{
|
||||||
|
Server: strings.TrimSpace(server),
|
||||||
|
Share: strings.TrimSpace(share),
|
||||||
|
MountPoint: filepath.Clean(strings.TrimSpace(mountPoint)),
|
||||||
|
Username: strings.TrimSpace(username),
|
||||||
|
Password: strings.TrimSpace(password),
|
||||||
|
Domain: strings.TrimSpace(domain),
|
||||||
|
UID: strings.TrimSpace(uid),
|
||||||
|
GID: strings.TrimSpace(gid),
|
||||||
|
FileMode: strings.TrimSpace(fileMode),
|
||||||
|
DirMode: strings.TrimSpace(dirMode),
|
||||||
|
AutoMount: autoMount,
|
||||||
|
ReadOnly: readOnly,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureMountPoint(path string) error {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("mount folder exists but is not a directory: %s", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("check mount folder %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.println("")
|
||||||
|
a.printf("The local mount folder %s does not exist yet.\n", path)
|
||||||
|
a.println("I can create it now.")
|
||||||
|
a.flush()
|
||||||
|
|
||||||
|
create, err := a.confirm("Create this folder now", true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !create {
|
||||||
|
return errors.New("a mount folder is needed before continuing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.createDirectory(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) appendFstabLine(line string) error {
|
||||||
|
content := line + "\n"
|
||||||
|
file, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0)
|
||||||
|
if err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
if _, err := file.WriteString(content); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !shouldOfferPrivilegeRetry(err) {
|
||||||
|
return fmt.Errorf("append mount entry to /etc/fstab: %w", err)
|
||||||
|
}
|
||||||
|
} else if !shouldOfferPrivilegeRetry(err) {
|
||||||
|
return fmt.Errorf("open /etc/fstab for update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.println("")
|
||||||
|
a.println("Updating /etc/fstab needs administrator permission.")
|
||||||
|
a.println("I can try again using sudo so you can enter your admin password.")
|
||||||
|
a.flush()
|
||||||
|
|
||||||
|
retry, err := a.confirm("Retry updating /etc/fstab with sudo", true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !retry {
|
||||||
|
return errors.New("updating /etc/fstab was cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempPath, err := a.writeTempConfig(content)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare fstab update: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
if err := a.runner.Run("sudo", "sh", "-c", fmt.Sprintf("cat %s >> /etc/fstab", shellQuote(tempPath))); err != nil {
|
||||||
|
return fmt.Errorf("append mount entry to /etc/fstab with sudo: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) mountCIFS(path string) error {
|
||||||
|
if err := a.runPrivilegedOrLocal("mount", []string{path}, "Mounting the network share needs administrator permission."); err != nil {
|
||||||
|
return fmt.Errorf("mount %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ensureShareDirectories(doc *Document) error {
|
func (a *App) ensureShareDirectories(doc *Document) error {
|
||||||
for _, section := range doc.ShareSections() {
|
for _, section := range doc.ShareSections() {
|
||||||
cfg := ShareFromSection(section)
|
cfg := ShareFromSection(section)
|
||||||
@@ -1054,6 +1317,53 @@ func friendlyWriteError(action, path string, err error) error {
|
|||||||
return fmt.Errorf("%s %s: %w", action, path, err)
|
return fmt.Errorf("%s %s: %w", action, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildFstabLine(cfg CIFSMountConfig) string {
|
||||||
|
source := fmt.Sprintf("//%s/%s", cfg.Server, cfg.Share)
|
||||||
|
|
||||||
|
options := []string{
|
||||||
|
"username=" + escapeFstabValue(cfg.Username),
|
||||||
|
"password=" + escapeFstabValue(cfg.Password),
|
||||||
|
"iocharset=utf8",
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Domain != "" {
|
||||||
|
options = append(options, "domain="+escapeFstabValue(cfg.Domain))
|
||||||
|
}
|
||||||
|
if cfg.UID != "" {
|
||||||
|
options = append(options, "uid="+escapeFstabValue(cfg.UID))
|
||||||
|
}
|
||||||
|
if cfg.GID != "" {
|
||||||
|
options = append(options, "gid="+escapeFstabValue(cfg.GID))
|
||||||
|
}
|
||||||
|
if cfg.FileMode != "" {
|
||||||
|
options = append(options, "file_mode="+cfg.FileMode)
|
||||||
|
}
|
||||||
|
if cfg.DirMode != "" {
|
||||||
|
options = append(options, "dir_mode="+cfg.DirMode)
|
||||||
|
}
|
||||||
|
if cfg.ReadOnly {
|
||||||
|
options = append(options, "ro")
|
||||||
|
} else {
|
||||||
|
options = append(options, "rw")
|
||||||
|
}
|
||||||
|
if !cfg.AutoMount {
|
||||||
|
options = append(options, "noauto")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s cifs %s 0 0", source, cfg.MountPoint, strings.Join(options, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeFstabValue(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, ",", `\,`)
|
||||||
|
value = strings.ReplaceAll(value, " ", `\040`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
type ShareUserReference struct {
|
type ShareUserReference struct {
|
||||||
User string
|
User string
|
||||||
Shares []string
|
Shares []string
|
||||||
|
|||||||
44
install.go
44
install.go
@@ -61,3 +61,47 @@ func DetectSambaInstallPlan(lookPath LookPathFunc, isRoot bool) (InstallPlan, bo
|
|||||||
|
|
||||||
return InstallPlan{}, false
|
return InstallPlan{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DetectCIFSUtilsInstallPlan(lookPath LookPathFunc, isRoot bool) (InstallPlan, bool) {
|
||||||
|
sudoPrefix := []string{}
|
||||||
|
if !isRoot {
|
||||||
|
if _, err := lookPath("sudo"); err == nil {
|
||||||
|
sudoPrefix = []string{"sudo"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type managerPlan struct {
|
||||||
|
bin string
|
||||||
|
name string
|
||||||
|
command []string
|
||||||
|
}
|
||||||
|
|
||||||
|
plans := []managerPlan{
|
||||||
|
{bin: "dnf", name: "dnf", command: []string{"dnf", "install", "-y", "cifs-utils"}},
|
||||||
|
{bin: "yum", name: "yum", command: []string{"yum", "install", "-y", "cifs-utils"}},
|
||||||
|
{bin: "pacman", name: "pacman", command: []string{"pacman", "-Sy", "--noconfirm", "cifs-utils"}},
|
||||||
|
{bin: "zypper", name: "zypper", command: []string{"zypper", "--non-interactive", "install", "cifs-utils"}},
|
||||||
|
{bin: "apk", name: "apk", command: []string{"apk", "add", "cifs-utils"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := lookPath("apt-get"); err == nil {
|
||||||
|
return InstallPlan{
|
||||||
|
ManagerName: "apt",
|
||||||
|
Command: append(
|
||||||
|
append([]string{}, sudoPrefix...),
|
||||||
|
"sh", "-c", "apt-get update && apt-get install -y cifs-utils",
|
||||||
|
),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, plan := range plans {
|
||||||
|
if _, err := lookPath(plan.bin); err == nil {
|
||||||
|
return InstallPlan{
|
||||||
|
ManagerName: plan.name,
|
||||||
|
Command: append(append([]string{}, sudoPrefix...), plan.command...),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return InstallPlan{}, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,6 +185,27 @@ func TestUnusedAccountCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFstabLine(t *testing.T) {
|
||||||
|
line := buildFstabLine(CIFSMountConfig{
|
||||||
|
Server: "fileserver",
|
||||||
|
Share: "media",
|
||||||
|
MountPoint: "/mnt/media",
|
||||||
|
Username: "alice",
|
||||||
|
Password: "p@ ss,word",
|
||||||
|
UID: "1000",
|
||||||
|
GID: "1000",
|
||||||
|
FileMode: "0664",
|
||||||
|
DirMode: "0775",
|
||||||
|
AutoMount: true,
|
||||||
|
ReadOnly: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
want := "//fileserver/media /mnt/media cifs username=alice,password=p@\\040ss\\,word,iocharset=utf8,uid=1000,gid=1000,file_mode=0664,dir_mode=0775,rw 0 0"
|
||||||
|
if line != want {
|
||||||
|
t.Fatalf("unexpected fstab line:\nwant: %s\ngot: %s", want, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type execErr string
|
type execErr string
|
||||||
|
|
||||||
func (e execErr) Error() string {
|
func (e execErr) Error() string {
|
||||||
|
|||||||
BIN
samba-configer
BIN
samba-configer
Binary file not shown.
Reference in New Issue
Block a user