mirror of
https://github.com/AlchemillaHQ/Sylve.git
synced 2026-06-14 00:46:34 +03:00
samba: init permissions improvements, fix guest access
This commit is contained in:
@@ -5,7 +5,7 @@ description: Managing samba shares on Sylve
|
||||
|
||||
## Pre-Requisites
|
||||
|
||||
You should have atleast 1 Group with the user you want to share the folder with. You can create a group and add users to it by following the Groups and Users guide.
|
||||
You should have local users and/or groups ready before creating a share. You can manage both from the Authentication section.
|
||||
|
||||
## Preface
|
||||
|
||||
@@ -26,17 +26,23 @@ Let's go through the fields one by one:
|
||||
- **Dataset**: The dataset you want to share, this is the path that will be shared over samba. You can create one in the ZFS/Filesystem section if you don't have one already.
|
||||
|
||||
|
||||
- **Read-Only Groups**: The groups that will have read-only access to the share, you can select multiple groups here. If a user belongs to multiple groups and one of them is selected here, they will have read-only access to the share.
|
||||
- **Read Users**: Users that get read access.
|
||||
|
||||
- **Writeable Groups**: The groups that will have read-write access to the share, you can select multiple groups here. If a user belongs to multiple groups and one of them is selected here, they will have read-write access to the share.
|
||||
- **Write Users**: Users that get write access.
|
||||
|
||||
- **Read Groups**: Groups that get read access.
|
||||
|
||||
- **Write Groups**: Groups that get write access.
|
||||
|
||||
Sylve uses **write-wins** normalization. If the same user/group is selected in both read and write lists, it is treated as write access.
|
||||
|
||||
- **Create Mask**: The permissions that will be set on files created in the share, this is a standard unix permission mask. The default value is `0664` which means that the owner and group will have read and write permissions, while others will have read-only permissions.
|
||||
|
||||
- **Directory Mask**: The permissions that will be set on directories created in the share, this is a standard unix permission mask. The default value is `0775` which means that the owner and group will have read, write and execute permissions, while others will have read and execute permissions.
|
||||
- **Directory Mask**: The permissions that will be set on directories created in the share, this is a standard unix permission mask. The default value is `2775` which means that the owner and group will have read, write and execute permissions, while others will have read and execute permissions.
|
||||
|
||||
- **Guest Access**: Whether to allow guest access to the share, if enabled users will be able to connect to the share without providing a username and password. This is disabled by default.
|
||||
- **Guest Only**: Whether to make this a guest-only share. If enabled, authenticated user/group access is disabled for that share.
|
||||
|
||||
- **Read-Only**: Whether to make the share read-only, if enabled users will only be able to read files from the share, but not write to it. This is disabled by default.
|
||||
- **Guest Writeable**: Only shown in guest-only mode. Controls whether guests can write or read-only.
|
||||
|
||||
## Editing a Samba Share
|
||||
|
||||
@@ -54,6 +60,10 @@ Never ever expose samba directly to the internet, if you want to access your sam
|
||||
|
||||
In this demonstration we'll be accessing the samba share from a macOS client over tailcale, but the process is similar for other operating systems as well.
|
||||
|
||||
:::note
|
||||
Modern Windows versions may block insecure SMB guest logons by default. If guest-only shares fail from Windows clients, check the client-side SMB guest logon policy.
|
||||
:::
|
||||
|
||||

|
||||
|
||||
In the "Connect to Server" dialog, enter the address of your Sylve server in the following format:
|
||||
@@ -66,4 +76,4 @@ smb://<IP_ADDRESS_OR_HOSTNAME>/<SHARE_NAME>
|
||||
|
||||
After the authentication is successful, you should see the contents of the share in the Finder.
|
||||
|
||||

|
||||

|
||||
|
||||
+764
-104
File diff suppressed because it is too large
Load Diff
+764
-104
File diff suppressed because it is too large
Load Diff
+506
-73
@@ -179,19 +179,6 @@ definitions:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare'
|
||||
type: array
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_utilities_CloudInitTemplate
|
||||
: properties:
|
||||
data:
|
||||
@@ -283,6 +270,19 @@ definitions:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry
|
||||
: properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry'
|
||||
type: array
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
? github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_SimpleList
|
||||
: properties:
|
||||
data:
|
||||
@@ -361,6 +361,19 @@ definitions:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaShareResponse'
|
||||
type: array
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal.APIResponse-github_com_alchemillahq_sylve_internal_db_models_BasicSettings:
|
||||
properties:
|
||||
data:
|
||||
@@ -628,6 +641,9 @@ definitions:
|
||||
- samba-server
|
||||
- virtualization
|
||||
- wol-server
|
||||
- firewall
|
||||
- wireguard
|
||||
- iscsi
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- DHCPServer
|
||||
@@ -635,6 +651,9 @@ definitions:
|
||||
- SambaServer
|
||||
- Virtualization
|
||||
- WoLServer
|
||||
- Firewall
|
||||
- WireGuard
|
||||
- ISCSI
|
||||
github_com_alchemillahq_sylve_internal_db_models.BasicSettings:
|
||||
properties:
|
||||
id:
|
||||
@@ -705,24 +724,44 @@ definitions:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
disablePassword:
|
||||
type: boolean
|
||||
doasEnabled:
|
||||
type: boolean
|
||||
email:
|
||||
type: string
|
||||
fullName:
|
||||
type: string
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
|
||||
type: array
|
||||
homeDirPerms:
|
||||
type: integer
|
||||
homeDirectory:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
lastLoginTime:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
notes:
|
||||
type: string
|
||||
primaryGroupId:
|
||||
type: integer
|
||||
shell:
|
||||
type: string
|
||||
sshPublicKey:
|
||||
type: string
|
||||
tokens:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Token'
|
||||
type: array
|
||||
totp:
|
||||
type: string
|
||||
uid:
|
||||
type: integer
|
||||
updatedAt:
|
||||
type: string
|
||||
username:
|
||||
@@ -1158,6 +1197,8 @@ definitions:
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_db_models_network.Object:
|
||||
properties:
|
||||
autoUpdate:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
@@ -1173,14 +1214,25 @@ definitions:
|
||||
isUsedBy:
|
||||
description: '"", "dhcp" for now'
|
||||
type: string
|
||||
lastRefreshAt:
|
||||
type: string
|
||||
lastRefreshError:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
refreshIntervalSeconds:
|
||||
type: integer
|
||||
resolutionChecksum:
|
||||
type: string
|
||||
resolutions:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_network.ObjectResolution'
|
||||
type: array
|
||||
sourceChecksum:
|
||||
type: string
|
||||
type:
|
||||
description: '"Host", "Mac", "Network", "Port", "Country", "List"'
|
||||
description: '"Host", "Mac", "Network", "Port", "Country", "List", "FQDN",
|
||||
"DUID"'
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
@@ -1208,7 +1260,9 @@ definitions:
|
||||
objectId:
|
||||
type: integer
|
||||
resolvedIp:
|
||||
description: actual IP resolved only in the case of FQDN
|
||||
description: deprecated mirror for resolved IP entries
|
||||
type: string
|
||||
resolvedValue:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
@@ -1297,6 +1351,8 @@ definitions:
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings:
|
||||
properties:
|
||||
appleExtensions:
|
||||
type: boolean
|
||||
bindInterfacesOnly:
|
||||
type: boolean
|
||||
id:
|
||||
@@ -1310,37 +1366,6 @@ definitions:
|
||||
workgroup:
|
||||
type: string
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare:
|
||||
properties:
|
||||
createMask:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
dataset:
|
||||
type: string
|
||||
directoryMask:
|
||||
type: string
|
||||
guestOk:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
readOnly:
|
||||
type: boolean
|
||||
readOnlyGroups:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
|
||||
type: array
|
||||
updatedAt:
|
||||
type: string
|
||||
writeableGroups:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group'
|
||||
type: array
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_db_models_utilities.CloudInitTemplate:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -1516,6 +1541,8 @@ definitions:
|
||||
type: boolean
|
||||
apic:
|
||||
type: boolean
|
||||
bootRom:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM'
|
||||
cloudInitData:
|
||||
type: string
|
||||
cloudInitMetaData:
|
||||
@@ -1536,6 +1563,10 @@ definitions:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
extraBhyveOptions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
id:
|
||||
type: integer
|
||||
ignoreUMSR:
|
||||
@@ -1584,6 +1615,8 @@ definitions:
|
||||
type: boolean
|
||||
updatedAt:
|
||||
type: string
|
||||
vncBind:
|
||||
type: string
|
||||
vncEnabled:
|
||||
type: boolean
|
||||
vncPassword:
|
||||
@@ -1597,6 +1630,14 @@ definitions:
|
||||
wol:
|
||||
type: boolean
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM:
|
||||
enum:
|
||||
- uefi
|
||||
- none
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- VMBootROMUEFI
|
||||
- VMBootROMNone
|
||||
github_com_alchemillahq_sylve_internal_db_models_vm.VMCPUPinning:
|
||||
properties:
|
||||
hostCpu:
|
||||
@@ -1983,6 +2024,48 @@ definitions:
|
||||
- name
|
||||
- switchName
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry:
|
||||
properties:
|
||||
dataset:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
exists:
|
||||
type: boolean
|
||||
label:
|
||||
type: string
|
||||
major:
|
||||
type: integer
|
||||
minor:
|
||||
type: integer
|
||||
mountPoint:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
pool:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest:
|
||||
properties:
|
||||
major:
|
||||
type: integer
|
||||
minor:
|
||||
type: integer
|
||||
pool:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- major
|
||||
- pool
|
||||
- type
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_interfaces_services_jail.CreateJailRequest:
|
||||
properties:
|
||||
additionalOptions:
|
||||
@@ -1993,6 +2076,8 @@ definitions:
|
||||
type: array
|
||||
base:
|
||||
type: string
|
||||
bootstrapName:
|
||||
type: string
|
||||
cleanEnvironment:
|
||||
type: boolean
|
||||
cores:
|
||||
@@ -2157,6 +2242,8 @@ definitions:
|
||||
type: boolean
|
||||
apic:
|
||||
type: boolean
|
||||
bootRom:
|
||||
type: string
|
||||
cloudInit:
|
||||
type: boolean
|
||||
cloudInitData:
|
||||
@@ -2177,6 +2264,10 @@ definitions:
|
||||
type: integer
|
||||
description:
|
||||
type: string
|
||||
extraBhyveOptions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ignoreUMSR:
|
||||
type: boolean
|
||||
iso:
|
||||
@@ -2217,6 +2308,10 @@ definitions:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.TimeOffset'
|
||||
tpmEmulation:
|
||||
type: boolean
|
||||
vncBind:
|
||||
type: string
|
||||
vncEnabled:
|
||||
type: boolean
|
||||
vncPassword:
|
||||
type: string
|
||||
vncPort:
|
||||
@@ -2265,6 +2360,8 @@ definitions:
|
||||
type: object
|
||||
github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.ModifyVNCRequest:
|
||||
properties:
|
||||
vncBind:
|
||||
type: string
|
||||
vncEnabled:
|
||||
type: boolean
|
||||
vncPassword:
|
||||
@@ -2658,6 +2755,8 @@ definitions:
|
||||
type: array
|
||||
datasetType:
|
||||
$ref: '#/definitions/gzfs.DatasetType'
|
||||
nameFilter:
|
||||
type: string
|
||||
page:
|
||||
type: integer
|
||||
search:
|
||||
@@ -3230,19 +3329,42 @@ definitions:
|
||||
properties:
|
||||
admin:
|
||||
type: boolean
|
||||
auxGroupIds:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
disablePassword:
|
||||
type: boolean
|
||||
doasEnabled:
|
||||
type: boolean
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
maxLength: 128
|
||||
minLength: 3
|
||||
fullName:
|
||||
type: string
|
||||
homeDirPerms:
|
||||
type: integer
|
||||
homeDirectory:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
newPrimaryGroup:
|
||||
type: boolean
|
||||
password:
|
||||
type: string
|
||||
primaryGroupId:
|
||||
type: integer
|
||||
shell:
|
||||
type: string
|
||||
sshPublicKey:
|
||||
type: string
|
||||
uid:
|
||||
type: integer
|
||||
username:
|
||||
maxLength: 128
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- admin
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
internal_handlers_auth.LoginRequest:
|
||||
@@ -3471,6 +3593,15 @@ definitions:
|
||||
ipv6:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_handlers_network.BulkDeleteNetworkObjectsRequest:
|
||||
properties:
|
||||
ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
required:
|
||||
- ids
|
||||
type: object
|
||||
internal_handlers_network.CreateManualSwitchRequest:
|
||||
properties:
|
||||
bridge:
|
||||
@@ -3598,23 +3729,21 @@ definitions:
|
||||
type: string
|
||||
directoryMask:
|
||||
type: string
|
||||
guestOk:
|
||||
type: boolean
|
||||
guest:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaGuestRequest'
|
||||
name:
|
||||
type: string
|
||||
readOnly:
|
||||
permissions:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsRequest'
|
||||
timeMachine:
|
||||
type: boolean
|
||||
readOnlyGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
writeableGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
timeMachineMaxSize:
|
||||
type: integer
|
||||
type: object
|
||||
internal_handlers_samba.SambaConfigRequest:
|
||||
properties:
|
||||
appleExtensions:
|
||||
type: boolean
|
||||
bindInterfacesOnly:
|
||||
type: boolean
|
||||
interfaces:
|
||||
@@ -3626,6 +3755,95 @@ definitions:
|
||||
workgroup:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_samba.SambaGuestRequest:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
writeable:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_handlers_samba.SambaGuestResponse:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
writeable:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_handlers_samba.SambaPermissionsRequest:
|
||||
properties:
|
||||
read:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest'
|
||||
write:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest'
|
||||
type: object
|
||||
internal_handlers_samba.SambaPermissionsResponse:
|
||||
properties:
|
||||
read:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalSetResponse'
|
||||
write:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalSetResponse'
|
||||
type: object
|
||||
internal_handlers_samba.SambaPrincipalGroupResponse:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_samba.SambaPrincipalIDsRequest:
|
||||
properties:
|
||||
groupIds:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
userIds:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
internal_handlers_samba.SambaPrincipalSetResponse:
|
||||
properties:
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalGroupResponse'
|
||||
type: array
|
||||
users:
|
||||
items:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPrincipalUserResponse'
|
||||
type: array
|
||||
type: object
|
||||
internal_handlers_samba.SambaPrincipalUserResponse:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_samba.SambaShareResponse:
|
||||
properties:
|
||||
createMask:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
dataset:
|
||||
type: string
|
||||
directoryMask:
|
||||
type: string
|
||||
guest:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaGuestResponse'
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
permissions:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsResponse'
|
||||
timeMachine:
|
||||
type: boolean
|
||||
timeMachineMaxSize:
|
||||
type: integer
|
||||
updatedAt:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_samba.UpdateSambaShareRequest:
|
||||
properties:
|
||||
createMask:
|
||||
@@ -3634,22 +3852,18 @@ definitions:
|
||||
type: string
|
||||
directoryMask:
|
||||
type: string
|
||||
guestOk:
|
||||
type: boolean
|
||||
guest:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaGuestRequest'
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
readOnly:
|
||||
permissions:
|
||||
$ref: '#/definitions/internal_handlers_samba.SambaPermissionsRequest'
|
||||
timeMachine:
|
||||
type: boolean
|
||||
readOnlyGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
writeableGroups:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
timeMachineMaxSize:
|
||||
type: integer
|
||||
type: object
|
||||
internal_handlers_system.AddFileOrFolderRequest:
|
||||
properties:
|
||||
@@ -3709,6 +3923,11 @@ definitions:
|
||||
startAtBoot:
|
||||
type: boolean
|
||||
type: object
|
||||
internal_handlers_vm.ModifyBootROMRequest:
|
||||
properties:
|
||||
bootRom:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_vm.ModifyClockRequest:
|
||||
properties:
|
||||
timeOffset:
|
||||
@@ -3723,6 +3942,13 @@ definitions:
|
||||
networkConfig:
|
||||
type: string
|
||||
type: object
|
||||
internal_handlers_vm.ModifyExtraBhyveOptionsRequest:
|
||||
properties:
|
||||
extraBhyveOptions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
internal_handlers_vm.ModifyIgnoreUMSRsRequest:
|
||||
properties:
|
||||
ignoreUMSRs:
|
||||
@@ -5496,6 +5722,115 @@ paths:
|
||||
summary: Perform Jail Action
|
||||
tags:
|
||||
- Jail
|
||||
/jail/bootstrap:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Destroy a completed pkgbase bootstrap dataset and remove its DB
|
||||
record
|
||||
parameters:
|
||||
- description: Pool name
|
||||
in: query
|
||||
name: pool
|
||||
required: true
|
||||
type: string
|
||||
- description: Bootstrap name (e.g. 15-0-Base)
|
||||
in: query
|
||||
name: name
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete bootstrap
|
||||
tags:
|
||||
- Jail
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Start a pkgbase bootstrap for the given pool, version, and type.
|
||||
Returns immediately; bootstrap runs asynchronously.
|
||||
parameters:
|
||||
- description: Bootstrap Request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create bootstrap
|
||||
tags:
|
||||
- Jail
|
||||
/jail/bootstraps:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: List all supported pkgbase bootstrap entries for a pool, with their
|
||||
current install status
|
||||
parameters:
|
||||
- description: Pool name
|
||||
in: query
|
||||
name: pool
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List bootstraps
|
||||
tags:
|
||||
- Jail
|
||||
/jail/cpu:
|
||||
put:
|
||||
consumes:
|
||||
@@ -6492,6 +6827,39 @@ paths:
|
||||
summary: Edit Network Object
|
||||
tags:
|
||||
- Network
|
||||
/network/object/bulk-delete:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Delete multiple network objects by their IDs; fails if any object
|
||||
is in use
|
||||
parameters:
|
||||
- description: Bulk Delete Request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers_network.BulkDeleteNetworkObjectsRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Objects deleted successfully
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Bulk Delete Network Objects
|
||||
tags:
|
||||
- Network
|
||||
/network/switch:
|
||||
get:
|
||||
consumes:
|
||||
@@ -6732,6 +7100,38 @@ paths:
|
||||
summary: Modify Boot Order of a Virtual Machine
|
||||
tags:
|
||||
- VM
|
||||
/options/boot-rom/:rid:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Modify the Boot ROM mode of a virtual machine
|
||||
parameters:
|
||||
- description: Modify Boot ROM Request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers_vm.ModifyBootROMRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Modify Boot ROM of a Virtual Machine
|
||||
tags:
|
||||
- VM
|
||||
/options/clock/:rid:
|
||||
put:
|
||||
consumes:
|
||||
@@ -6828,6 +7228,39 @@ paths:
|
||||
summary: Modify DevFS rules of a Jail
|
||||
tags:
|
||||
- Jail
|
||||
/options/extra-bhyve-options/:rid:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Modify custom bhyve arguments (one argument per line) of a virtual
|
||||
machine
|
||||
parameters:
|
||||
- description: Modify Extra Bhyve Options Request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_handlers_vm.ModifyExtraBhyveOptionsRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Modify Extra Bhyve Options of a Virtual Machine
|
||||
tags:
|
||||
- VM
|
||||
/options/fstab/:rid:
|
||||
put:
|
||||
consumes:
|
||||
@@ -7273,7 +7706,7 @@ paths:
|
||||
"200":
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare'
|
||||
$ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
|
||||
+1139
-110
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func Fixups(db *gorm.DB) error {
|
||||
cleanupInvalidAuditUserIDs(db)
|
||||
cleanupLegacyDevdEventsTable(db)
|
||||
dropSambaSharePathUniqueIndex(db)
|
||||
normalizeSambaSharePermissionOverlaps(db)
|
||||
backfillFirewallRuleVisibilityDefaults(db)
|
||||
backfillUserSystemData(db)
|
||||
|
||||
@@ -328,6 +329,91 @@ func dropSambaSharePathUniqueIndex(db *gorm.DB) {
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSambaSharePermissionOverlaps(db *gorm.DB) {
|
||||
const name = "normalize_samba_share_permission_overlaps_1"
|
||||
|
||||
var count int64
|
||||
if err := db.
|
||||
Table("migrations").
|
||||
Where("name = ?", name).
|
||||
Count(&count).Error; err != nil {
|
||||
logger.L.Err(err).Msg("migration check failed for normalize_samba_share_permission_overlaps")
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&sambaModels.SambaShare{}) {
|
||||
db.Table("migrations").Create(map[string]any{"name": name})
|
||||
return
|
||||
}
|
||||
|
||||
var shares []sambaModels.SambaShare
|
||||
if err := db.
|
||||
Preload("ReadOnlyUsers").
|
||||
Preload("WriteableUsers").
|
||||
Preload("ReadOnlyGroups").
|
||||
Preload("WriteableGroups").
|
||||
Find(&shares).Error; err != nil {
|
||||
logger.L.Err(err).Msg("failed loading samba shares for permission overlap normalization")
|
||||
return
|
||||
}
|
||||
|
||||
for i := range shares {
|
||||
share := shares[i]
|
||||
|
||||
writeUserIDs := make(map[uint]struct{}, len(share.WriteableUsers))
|
||||
for _, user := range share.WriteableUsers {
|
||||
writeUserIDs[user.ID] = struct{}{}
|
||||
}
|
||||
|
||||
writeGroupIDs := make(map[uint]struct{}, len(share.WriteableGroups))
|
||||
for _, group := range share.WriteableGroups {
|
||||
writeGroupIDs[group.ID] = struct{}{}
|
||||
}
|
||||
|
||||
filteredReadUsers := make([]authModels.User, 0, len(share.ReadOnlyUsers))
|
||||
userChanged := false
|
||||
for _, user := range share.ReadOnlyUsers {
|
||||
if _, exists := writeUserIDs[user.ID]; exists {
|
||||
userChanged = true
|
||||
continue
|
||||
}
|
||||
filteredReadUsers = append(filteredReadUsers, user)
|
||||
}
|
||||
|
||||
filteredReadGroups := make([]authModels.Group, 0, len(share.ReadOnlyGroups))
|
||||
groupChanged := false
|
||||
for _, group := range share.ReadOnlyGroups {
|
||||
if _, exists := writeGroupIDs[group.ID]; exists {
|
||||
groupChanged = true
|
||||
continue
|
||||
}
|
||||
filteredReadGroups = append(filteredReadGroups, group)
|
||||
}
|
||||
|
||||
if userChanged {
|
||||
if err := db.Model(&share).Association("ReadOnlyUsers").Replace(filteredReadUsers); err != nil {
|
||||
logger.L.Err(err).Int("share_id", share.ID).Msg("failed normalizing samba share user permission overlap")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if groupChanged {
|
||||
if err := db.Model(&share).Association("ReadOnlyGroups").Replace(filteredReadGroups); err != nil {
|
||||
logger.L.Err(err).Int("share_id", share.ID).Msg("failed normalizing samba share group permission overlap")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.Table("migrations").Create(map[string]any{
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
func backfillFirewallRuleVisibilityDefaults(db *gorm.DB) {
|
||||
const name = "backfill_firewall_rule_visibility_defaults_1"
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ type SambaShare struct {
|
||||
Name string `json:"name" gorm:"uniqueIndex"`
|
||||
Dataset string `json:"dataset" gorm:"uniqueIndex"`
|
||||
Path string `json:"path"`
|
||||
ReadOnlyUsers []models.User `json:"readOnlyUsers" gorm:"many2many:samba_share_read_only_users;"`
|
||||
WriteableUsers []models.User `json:"writeableUsers" gorm:"many2many:samba_share_writeable_users;"`
|
||||
ReadOnlyGroups []models.Group `json:"readOnlyGroups" gorm:"many2many:samba_share_read_only_groups;"`
|
||||
WriteableGroups []models.Group `json:"writeableGroups" gorm:"many2many:samba_share_writeable_groups;"`
|
||||
CreateMask string `json:"createMask" gorm:"default:'0664'"`
|
||||
|
||||
@@ -9,41 +9,192 @@
|
||||
package sambaHandlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal"
|
||||
authModels "github.com/alchemillahq/sylve/internal/db/models"
|
||||
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
|
||||
"github.com/alchemillahq/sylve/internal/services/samba"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SambaPrincipalIDsRequest struct {
|
||||
UserIDs []uint `json:"userIds"`
|
||||
GroupIDs []uint `json:"groupIds"`
|
||||
}
|
||||
|
||||
type SambaPermissionsRequest struct {
|
||||
Read SambaPrincipalIDsRequest `json:"read"`
|
||||
Write SambaPrincipalIDsRequest `json:"write"`
|
||||
}
|
||||
|
||||
type SambaGuestRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Writeable bool `json:"writeable"`
|
||||
}
|
||||
|
||||
type CreateSambaShareRequest struct {
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
ReadOnlyGroups []string `json:"readOnlyGroups"`
|
||||
WriteableGroups []string `json:"writeableGroups"`
|
||||
CreateMask string `json:"createMask"`
|
||||
DirectoryMask string `json:"directoryMask"`
|
||||
GuestOk *bool `json:"guestOk"`
|
||||
ReadOnly *bool `json:"readOnly"`
|
||||
TimeMachine *bool `json:"timeMachine"`
|
||||
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
Permissions SambaPermissionsRequest `json:"permissions"`
|
||||
Guest SambaGuestRequest `json:"guest"`
|
||||
CreateMask string `json:"createMask"`
|
||||
DirectoryMask string `json:"directoryMask"`
|
||||
TimeMachine *bool `json:"timeMachine"`
|
||||
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
|
||||
}
|
||||
|
||||
type UpdateSambaShareRequest struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
ReadOnlyGroups []string `json:"readOnlyGroups"`
|
||||
WriteableGroups []string `json:"writeableGroups"`
|
||||
CreateMask string `json:"createMask"`
|
||||
DirectoryMask string `json:"directoryMask"`
|
||||
GuestOk *bool `json:"guestOk"`
|
||||
ReadOnly *bool `json:"readOnly"`
|
||||
TimeMachine *bool `json:"timeMachine"`
|
||||
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
Permissions SambaPermissionsRequest `json:"permissions"`
|
||||
Guest SambaGuestRequest `json:"guest"`
|
||||
CreateMask string `json:"createMask"`
|
||||
DirectoryMask string `json:"directoryMask"`
|
||||
TimeMachine *bool `json:"timeMachine"`
|
||||
TimeMachineMaxSize *uint64 `json:"timeMachineMaxSize"`
|
||||
}
|
||||
|
||||
type SambaPrincipalUserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type SambaPrincipalGroupResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SambaPrincipalSetResponse struct {
|
||||
Users []SambaPrincipalUserResponse `json:"users"`
|
||||
Groups []SambaPrincipalGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
type SambaPermissionsResponse struct {
|
||||
Read SambaPrincipalSetResponse `json:"read"`
|
||||
Write SambaPrincipalSetResponse `json:"write"`
|
||||
}
|
||||
|
||||
type SambaGuestResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Writeable bool `json:"writeable"`
|
||||
}
|
||||
|
||||
type SambaShareResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
Permissions SambaPermissionsResponse `json:"permissions"`
|
||||
Guest SambaGuestResponse `json:"guest"`
|
||||
CreateMask string `json:"createMask"`
|
||||
DirectoryMask string `json:"directoryMask"`
|
||||
TimeMachine bool `json:"timeMachine"`
|
||||
TimeMachineMaxSize uint64 `json:"timeMachineMaxSize"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func strictJSONBind(c *gin.Context, dst any) error {
|
||||
raw, err := c.GetRawData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("invalid_json_payload")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapUsers(users []authModels.User) []SambaPrincipalUserResponse {
|
||||
mapped := make([]SambaPrincipalUserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
mapped = append(mapped, SambaPrincipalUserResponse{ID: user.ID, Username: user.Username})
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func mapGroups(groups []authModels.Group) []SambaPrincipalGroupResponse {
|
||||
mapped := make([]SambaPrincipalGroupResponse, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
mapped = append(mapped, SambaPrincipalGroupResponse{ID: group.ID, Name: group.Name})
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func mapShareResponse(share sambaModels.SambaShare) SambaShareResponse {
|
||||
guestWriteable := share.GuestOk && !share.ReadOnly
|
||||
|
||||
return SambaShareResponse{
|
||||
ID: share.ID,
|
||||
Name: share.Name,
|
||||
Dataset: share.Dataset,
|
||||
Permissions: SambaPermissionsResponse{
|
||||
Read: SambaPrincipalSetResponse{
|
||||
Users: mapUsers(share.ReadOnlyUsers),
|
||||
Groups: mapGroups(share.ReadOnlyGroups),
|
||||
},
|
||||
Write: SambaPrincipalSetResponse{
|
||||
Users: mapUsers(share.WriteableUsers),
|
||||
Groups: mapGroups(share.WriteableGroups),
|
||||
},
|
||||
},
|
||||
Guest: SambaGuestResponse{
|
||||
Enabled: share.GuestOk,
|
||||
Writeable: guestWriteable,
|
||||
},
|
||||
CreateMask: share.CreateMask,
|
||||
DirectoryMask: share.DirectoryMask,
|
||||
TimeMachine: share.TimeMachine,
|
||||
TimeMachineMaxSize: share.TimeMachineMaxSize,
|
||||
CreatedAt: share.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: share.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
func sambaShareServiceErrorStatus(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
|
||||
switch {
|
||||
case msg == "share_with_name_exists", msg == "share_with_dataset_exists":
|
||||
return http.StatusConflict
|
||||
case strings.HasPrefix(msg, "share_not_found"):
|
||||
return http.StatusNotFound
|
||||
case msg == "guest_only_share_cannot_have_principals",
|
||||
msg == "no_principals_selected_and_guests_not_allowed",
|
||||
msg == "dataset_not_found",
|
||||
msg == "dataset_not_mounted",
|
||||
strings.HasPrefix(msg, "user_not_found:"),
|
||||
strings.HasPrefix(msg, "group_not_found:"),
|
||||
strings.Contains(msg, "dataset_not_filesystem:"):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Get Samba Shares
|
||||
@@ -51,7 +202,7 @@ type UpdateSambaShareRequest struct {
|
||||
// @Tags Samba
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} internal.APIResponse[[]sambaModels.SambaShare] "Success"
|
||||
// @Success 200 {object} internal.APIResponse[[]SambaShareResponse] "Success"
|
||||
// @Failure 500 {object} internal.APIResponse[any] "Internal Server Error"
|
||||
// @Router /samba/shares [get]
|
||||
func GetShares(smbService *samba.Service) gin.HandlerFunc {
|
||||
@@ -67,11 +218,16 @@ func GetShares(smbService *samba.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, internal.APIResponse[[]sambaModels.SambaShare]{
|
||||
mapped := make([]SambaShareResponse, 0, len(shares))
|
||||
for _, share := range shares {
|
||||
mapped = append(mapped, mapShareResponse(share))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, internal.APIResponse[[]SambaShareResponse]{
|
||||
Status: "success",
|
||||
Message: "shares_retrieved",
|
||||
Error: "",
|
||||
Data: shares,
|
||||
Data: mapped,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,7 +245,7 @@ func GetShares(smbService *samba.Service) gin.HandlerFunc {
|
||||
func CreateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request CreateSambaShareRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
if err := strictJSONBind(c, &request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "invalid_request",
|
||||
@@ -99,18 +255,6 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
guestOk := false
|
||||
|
||||
if request.GuestOk != nil {
|
||||
guestOk = *request.GuestOk
|
||||
}
|
||||
|
||||
readOnly := false
|
||||
|
||||
if request.ReadOnly != nil {
|
||||
readOnly = *request.ReadOnly
|
||||
}
|
||||
|
||||
timeMachine := false
|
||||
if request.TimeMachine != nil {
|
||||
timeMachine = *request.TimeMachine
|
||||
@@ -126,16 +270,18 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
ctx,
|
||||
request.Name,
|
||||
request.Dataset,
|
||||
request.ReadOnlyGroups,
|
||||
request.WriteableGroups,
|
||||
request.Permissions.Read.UserIDs,
|
||||
request.Permissions.Write.UserIDs,
|
||||
request.Permissions.Read.GroupIDs,
|
||||
request.Permissions.Write.GroupIDs,
|
||||
request.Guest.Enabled,
|
||||
request.Guest.Writeable,
|
||||
request.CreateMask,
|
||||
request.DirectoryMask,
|
||||
guestOk,
|
||||
readOnly,
|
||||
timeMachine,
|
||||
timeMachineMaxSize,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "failed_to_create_share",
|
||||
Error: err.Error(),
|
||||
@@ -166,7 +312,7 @@ func CreateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request UpdateSambaShareRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
if err := strictJSONBind(c, &request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "invalid_request",
|
||||
@@ -176,18 +322,6 @@ func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
guestOk := false
|
||||
|
||||
if request.GuestOk != nil {
|
||||
guestOk = *request.GuestOk
|
||||
}
|
||||
|
||||
readOnly := false
|
||||
|
||||
if request.ReadOnly != nil {
|
||||
readOnly = *request.ReadOnly
|
||||
}
|
||||
|
||||
timeMachine := false
|
||||
if request.TimeMachine != nil {
|
||||
timeMachine = *request.TimeMachine
|
||||
@@ -204,16 +338,18 @@ func UpdateShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
request.ID,
|
||||
request.Name,
|
||||
request.Dataset,
|
||||
request.ReadOnlyGroups,
|
||||
request.WriteableGroups,
|
||||
request.Permissions.Read.UserIDs,
|
||||
request.Permissions.Write.UserIDs,
|
||||
request.Permissions.Read.GroupIDs,
|
||||
request.Permissions.Write.GroupIDs,
|
||||
request.Guest.Enabled,
|
||||
request.Guest.Writeable,
|
||||
request.CreateMask,
|
||||
request.DirectoryMask,
|
||||
guestOk,
|
||||
readOnly,
|
||||
timeMachine,
|
||||
timeMachineMaxSize,
|
||||
); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "failed_to_update_share",
|
||||
Error: err.Error(),
|
||||
@@ -258,7 +394,7 @@ func DeleteShare(smbService *samba.Service) gin.HandlerFunc {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
if err := smbService.DeleteShare(ctx, uint(idInt)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
|
||||
c.JSON(sambaShareServiceErrorStatus(err), internal.APIResponse[any]{
|
||||
Status: "error",
|
||||
Message: "failed_to_delete_share",
|
||||
Error: err.Error(),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package sambaHandlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authModels "github.com/alchemillahq/sylve/internal/db/models"
|
||||
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
|
||||
"github.com/alchemillahq/sylve/internal/services/samba"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newSambaSharesRouter(smbService *samba.Service) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/samba/shares", GetShares(smbService))
|
||||
r.POST("/samba/shares", CreateShare(smbService))
|
||||
r.PUT("/samba/shares", UpdateShare(smbService))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCreateShareRejectsLegacyV1PayloadFields(t *testing.T) {
|
||||
router := newSambaSharesRouter(&samba.Service{})
|
||||
|
||||
body := []byte(`{
|
||||
"name":"legacy-share",
|
||||
"dataset":"dataset-guid",
|
||||
"readOnlyGroups":["staff"],
|
||||
"writeableGroups":["admins"],
|
||||
"guestOk":false
|
||||
}`)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Message != "invalid_request" {
|
||||
t.Fatalf("expected invalid_request, got %q", resp.Message)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "unknown field") {
|
||||
t.Fatalf("expected strict unknown field error, got %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShareRejectsLegacyV1PayloadFields(t *testing.T) {
|
||||
router := newSambaSharesRouter(&samba.Service{})
|
||||
|
||||
body := []byte(`{
|
||||
"id":1,
|
||||
"name":"legacy-share",
|
||||
"dataset":"dataset-guid",
|
||||
"readOnlyGroups":["staff"],
|
||||
"writeableGroups":["admins"],
|
||||
"guestOk":false
|
||||
}`)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodPut, "/samba/shares", body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Message != "invalid_request" {
|
||||
t.Fatalf("expected invalid_request, got %q", resp.Message)
|
||||
}
|
||||
if !strings.Contains(resp.Error, "unknown field") {
|
||||
t.Fatalf("expected strict unknown field error, got %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSharesReturnsExpandedV2Permissions(t *testing.T) {
|
||||
db := newSambaHandlerTestDB(
|
||||
t,
|
||||
&authModels.User{},
|
||||
&authModels.Group{},
|
||||
&sambaModels.SambaShare{},
|
||||
)
|
||||
|
||||
readUser := authModels.User{Username: "alice"}
|
||||
writeUser := authModels.User{Username: "bob"}
|
||||
readGroup := authModels.Group{Name: "staff_ro"}
|
||||
writeGroup := authModels.Group{Name: "staff_rw"}
|
||||
|
||||
if err := db.Create(&readUser).Error; err != nil {
|
||||
t.Fatalf("failed creating read user: %v", err)
|
||||
}
|
||||
if err := db.Create(&writeUser).Error; err != nil {
|
||||
t.Fatalf("failed creating write user: %v", err)
|
||||
}
|
||||
if err := db.Create(&readGroup).Error; err != nil {
|
||||
t.Fatalf("failed creating read group: %v", err)
|
||||
}
|
||||
if err := db.Create(&writeGroup).Error; err != nil {
|
||||
t.Fatalf("failed creating write group: %v", err)
|
||||
}
|
||||
|
||||
share := sambaModels.SambaShare{
|
||||
Name: "secure",
|
||||
Dataset: "dataset-guid",
|
||||
GuestOk: false,
|
||||
ReadOnly: true,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := db.Create(&share).Error; err != nil {
|
||||
t.Fatalf("failed creating share: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Model(&share).Association("ReadOnlyUsers").Append(&readUser); err != nil {
|
||||
t.Fatalf("failed appending read user: %v", err)
|
||||
}
|
||||
if err := db.Model(&share).Association("WriteableUsers").Append(&writeUser); err != nil {
|
||||
t.Fatalf("failed appending write user: %v", err)
|
||||
}
|
||||
if err := db.Model(&share).Association("ReadOnlyGroups").Append(&readGroup); err != nil {
|
||||
t.Fatalf("failed appending read group: %v", err)
|
||||
}
|
||||
if err := db.Model(&share).Association("WriteableGroups").Append(&writeGroup); err != nil {
|
||||
t.Fatalf("failed appending write group: %v", err)
|
||||
}
|
||||
|
||||
svc := &samba.Service{DB: db}
|
||||
router := newSambaSharesRouter(svc)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodGet, "/samba/shares", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[[]SambaShareResponse]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Data) != 1 {
|
||||
t.Fatalf("expected one share in response, got %d", len(resp.Data))
|
||||
}
|
||||
|
||||
got := resp.Data[0]
|
||||
if got.Name != "secure" || got.Dataset != "dataset-guid" {
|
||||
t.Fatalf("unexpected share identity payload: %+v", got)
|
||||
}
|
||||
|
||||
if got.Guest.Enabled {
|
||||
t.Fatalf("expected authenticated share to have guest.enabled=false")
|
||||
}
|
||||
if got.Guest.Writeable {
|
||||
t.Fatalf("expected authenticated share to have guest.writeable=false")
|
||||
}
|
||||
|
||||
if len(got.Permissions.Read.Users) != 1 || got.Permissions.Read.Users[0].Username != "alice" {
|
||||
t.Fatalf("unexpected read users payload: %+v", got.Permissions.Read.Users)
|
||||
}
|
||||
if len(got.Permissions.Write.Users) != 1 || got.Permissions.Write.Users[0].Username != "bob" {
|
||||
t.Fatalf("unexpected write users payload: %+v", got.Permissions.Write.Users)
|
||||
}
|
||||
if len(got.Permissions.Read.Groups) != 1 || got.Permissions.Read.Groups[0].Name != "staff_ro" {
|
||||
t.Fatalf("unexpected read groups payload: %+v", got.Permissions.Read.Groups)
|
||||
}
|
||||
if len(got.Permissions.Write.Groups) != 1 || got.Permissions.Write.Groups[0].Name != "staff_rw" {
|
||||
t.Fatalf("unexpected write groups payload: %+v", got.Permissions.Write.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareReturnsConflictForDuplicateName(t *testing.T) {
|
||||
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
|
||||
if err := db.Create(&sambaModels.SambaShare{
|
||||
Name: "dupe",
|
||||
Dataset: "dataset-guid-1",
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed share: %v", err)
|
||||
}
|
||||
|
||||
svc := &samba.Service{DB: db}
|
||||
router := newSambaSharesRouter(svc)
|
||||
|
||||
body := []byte(`{
|
||||
"name":"dupe",
|
||||
"dataset":"dataset-guid-2",
|
||||
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
|
||||
"guest":{"enabled":true,"writeable":false},
|
||||
"createMask":"0664",
|
||||
"directoryMask":"2775"
|
||||
}`)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("expected status 409, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Error != "share_with_name_exists" {
|
||||
t.Fatalf("expected share_with_name_exists error, got %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareReturnsBadRequestForInvalidPrincipalMode(t *testing.T) {
|
||||
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
|
||||
svc := &samba.Service{DB: db}
|
||||
router := newSambaSharesRouter(svc)
|
||||
|
||||
body := []byte(`{
|
||||
"name":"invalid",
|
||||
"dataset":"dataset-guid-2",
|
||||
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
|
||||
"guest":{"enabled":false,"writeable":false},
|
||||
"createMask":"0664",
|
||||
"directoryMask":"2775"
|
||||
}`)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodPost, "/samba/shares", body)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Error != "no_principals_selected_and_guests_not_allowed" {
|
||||
t.Fatalf("unexpected error: %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShareReturnsNotFoundWhenShareIsMissing(t *testing.T) {
|
||||
db := newSambaHandlerTestDB(t, &sambaModels.SambaShare{})
|
||||
svc := &samba.Service{DB: db}
|
||||
router := newSambaSharesRouter(svc)
|
||||
|
||||
body := []byte(`{
|
||||
"id":999,
|
||||
"name":"missing",
|
||||
"dataset":"dataset-guid",
|
||||
"permissions":{"read":{"userIds":[],"groupIds":[]},"write":{"userIds":[],"groupIds":[]}},
|
||||
"guest":{"enabled":true,"writeable":false},
|
||||
"createMask":"0664",
|
||||
"directoryMask":"2775"
|
||||
}`)
|
||||
|
||||
rr := performSambaJSONRequest(t, router, http.MethodPut, "/samba/shares", body)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp handlerAPIResponse[any]
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Error, "share_not_found") {
|
||||
t.Fatalf("expected share_not_found error, got %q", resp.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package sambaHandlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal/testutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type handlerAPIResponse[T any] struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data T `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func newSambaHandlerTestDB(t *testing.T, migrateModels ...any) *gorm.DB {
|
||||
t.Helper()
|
||||
return testutil.NewSQLiteTestDB(t, migrateModels...)
|
||||
}
|
||||
|
||||
func performSambaJSONRequest(
|
||||
t *testing.T,
|
||||
router *gin.Engine,
|
||||
method string,
|
||||
path string,
|
||||
body []byte,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return testutil.PerformJSONRequest(t, router, method, path, body)
|
||||
}
|
||||
@@ -23,6 +23,12 @@ import (
|
||||
iface "github.com/alchemillahq/sylve/pkg/network/iface"
|
||||
)
|
||||
|
||||
const (
|
||||
sambaACLType = "nfsv4"
|
||||
sambaACLMode = "restricted"
|
||||
sambaACLInherit = "passthrough"
|
||||
)
|
||||
|
||||
func (s *Service) GetGlobalConfig() (sambaModels.SambaSettings, error) {
|
||||
var settings sambaModels.SambaSettings
|
||||
if err := s.DB.First(&settings).Error; err != nil {
|
||||
@@ -100,6 +106,226 @@ func (s *Service) SetGlobalConfig(
|
||||
return s.WriteConfig(ctx, true)
|
||||
}
|
||||
|
||||
func (s *Service) hasGuestOnlyShares() (bool, error) {
|
||||
var count int64
|
||||
if err := s.DB.Model(&sambaModels.SambaShare{}).Where("guest_ok = ?", true).Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("failed_to_check_guest_shares: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureSambaDatasetACLProperties(
|
||||
ctx context.Context,
|
||||
dataset *gzfs.Dataset,
|
||||
strict bool,
|
||||
) error {
|
||||
if dataset == nil {
|
||||
err := fmt.Errorf("dataset_not_found")
|
||||
if strict {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.L.Warn().Err(err).Msg("failed_to_enforce_samba_dataset_acl_properties")
|
||||
return nil
|
||||
}
|
||||
|
||||
if dataset.Type != gzfs.DatasetTypeFilesystem {
|
||||
err := fmt.Errorf("dataset_not_filesystem: %s", dataset.Name)
|
||||
if strict {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.L.Warn().Err(err).Str("dataset", dataset.Name).Msg("failed_to_enforce_samba_dataset_acl_properties")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := dataset.SetProperties(
|
||||
ctx,
|
||||
"acltype", sambaACLType,
|
||||
"aclmode", sambaACLMode,
|
||||
"aclinherit", sambaACLInherit,
|
||||
); err != nil {
|
||||
wrapped := fmt.Errorf("failed_to_set_samba_acl_properties_for_dataset_%s: %w", dataset.Name, err)
|
||||
if strict {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
logger.L.Warn().Err(wrapped).Str("dataset", dataset.Name).Msg("failed_to_enforce_samba_dataset_acl_properties")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniquePrincipalNames(names []string) []string {
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
out := make([]string, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSambaPrincipalNames(input sambaPrincipalNames) sambaPrincipalNames {
|
||||
normalized := sambaPrincipalNames{
|
||||
ReadUsers: uniquePrincipalNames(input.ReadUsers),
|
||||
WriteUsers: uniquePrincipalNames(input.WriteUsers),
|
||||
ReadGroups: uniquePrincipalNames(input.ReadGroups),
|
||||
WriteGroups: uniquePrincipalNames(input.WriteGroups),
|
||||
}
|
||||
|
||||
writeUsers := make(map[string]struct{}, len(normalized.WriteUsers))
|
||||
for _, user := range normalized.WriteUsers {
|
||||
writeUsers[user] = struct{}{}
|
||||
}
|
||||
|
||||
filteredReadUsers := make([]string, 0, len(normalized.ReadUsers))
|
||||
for _, user := range normalized.ReadUsers {
|
||||
if _, exists := writeUsers[user]; exists {
|
||||
continue
|
||||
}
|
||||
filteredReadUsers = append(filteredReadUsers, user)
|
||||
}
|
||||
normalized.ReadUsers = filteredReadUsers
|
||||
|
||||
writeGroups := make(map[string]struct{}, len(normalized.WriteGroups))
|
||||
for _, group := range normalized.WriteGroups {
|
||||
writeGroups[group] = struct{}{}
|
||||
}
|
||||
|
||||
filteredReadGroups := make([]string, 0, len(normalized.ReadGroups))
|
||||
for _, group := range normalized.ReadGroups {
|
||||
if _, exists := writeGroups[group]; exists {
|
||||
continue
|
||||
}
|
||||
filteredReadGroups = append(filteredReadGroups, group)
|
||||
}
|
||||
normalized.ReadGroups = filteredReadGroups
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func mergePrincipalNames(lists ...[]string) []string {
|
||||
merged := make([]string, 0)
|
||||
for _, list := range lists {
|
||||
merged = append(merged, list...)
|
||||
}
|
||||
return uniquePrincipalNames(merged)
|
||||
}
|
||||
|
||||
func (s *Service) syncSambaDatasetPrincipalACLs(
|
||||
mountpoint string,
|
||||
previous sambaPrincipalNames,
|
||||
desired sambaPrincipalNames,
|
||||
strict bool,
|
||||
) error {
|
||||
if mountpoint == "" || mountpoint == "-" {
|
||||
err := fmt.Errorf("dataset_not_mounted")
|
||||
if strict {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.L.Warn().Err(err).Str("mountpoint", mountpoint).Msg("failed_to_enforce_samba_dataset_principal_acls")
|
||||
return nil
|
||||
}
|
||||
|
||||
previous = normalizeSambaPrincipalNames(previous)
|
||||
desired = normalizeSambaPrincipalNames(desired)
|
||||
|
||||
removeACL := func(principalType string, principalName string, permissionSet string) {
|
||||
entry := fmt.Sprintf("%s:%s:%s:fd:allow", principalType, principalName, permissionSet)
|
||||
|
||||
if _, err := utils.RunCommand("/bin/setfacl", "-x", entry, mountpoint); err != nil {
|
||||
logger.L.Warn().
|
||||
Err(err).
|
||||
Str("principal", principalName).
|
||||
Str("principal_type", principalType).
|
||||
Str("permission_set", permissionSet).
|
||||
Str("mountpoint", mountpoint).
|
||||
Msg("failed_to_remove_samba_dataset_principal_acl_entry")
|
||||
}
|
||||
}
|
||||
|
||||
addACL := func(principalType string, principalName string, permissionSet string) error {
|
||||
entry := fmt.Sprintf("%s:%s:%s:fd:allow", principalType, principalName, permissionSet)
|
||||
|
||||
_, err := utils.RunCommand("/bin/setfacl", "-m", entry, mountpoint)
|
||||
if err != nil {
|
||||
wrapped := fmt.Errorf(
|
||||
"failed_to_set_acl_for_%s_%s_on_%s: %w",
|
||||
principalType,
|
||||
principalName,
|
||||
mountpoint,
|
||||
err,
|
||||
)
|
||||
if strict {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
logger.L.Warn().
|
||||
Err(wrapped).
|
||||
Str("principal", principalName).
|
||||
Str("principal_type", principalType).
|
||||
Str("permission_set", permissionSet).
|
||||
Str("mountpoint", mountpoint).
|
||||
Msg("failed_to_enforce_samba_dataset_principal_acls")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
targetUsers := mergePrincipalNames(previous.ReadUsers, previous.WriteUsers, desired.ReadUsers, desired.WriteUsers)
|
||||
targetGroups := mergePrincipalNames(previous.ReadGroups, previous.WriteGroups, desired.ReadGroups, desired.WriteGroups)
|
||||
|
||||
for _, user := range targetUsers {
|
||||
removeACL("u", user, "read_set")
|
||||
removeACL("u", user, "modify_set")
|
||||
}
|
||||
|
||||
for _, group := range targetGroups {
|
||||
removeACL("g", group, "read_set")
|
||||
removeACL("g", group, "modify_set")
|
||||
}
|
||||
|
||||
for _, user := range desired.ReadUsers {
|
||||
if err := addACL("u", user, "read_set"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, user := range desired.WriteUsers {
|
||||
if err := addACL("u", user, "modify_set"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range desired.ReadGroups {
|
||||
if err := addACL("g", group, "read_set"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range desired.WriteGroups {
|
||||
if err := addACL("g", group, "modify_set"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GlobalConfig() (string, error) {
|
||||
settings, err := s.GetGlobalConfig()
|
||||
if err != nil {
|
||||
@@ -129,6 +355,15 @@ func (s *Service) GlobalConfig() (string, error) {
|
||||
config += "bind interfaces only = no\n"
|
||||
}
|
||||
|
||||
hasGuestShares, err := s.hasGuestOnlyShares()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if hasGuestShares {
|
||||
config += "map to guest = Bad User\n"
|
||||
}
|
||||
|
||||
if settings.AppleExtensions {
|
||||
config += "min protocol = SMB2\n"
|
||||
config += "ea support = yes\n"
|
||||
@@ -150,7 +385,12 @@ func (s *Service) GlobalConfig() (string, error) {
|
||||
|
||||
func (s *Service) ShareConfig(ctx context.Context) (string, error) {
|
||||
shares := []sambaModels.SambaShare{}
|
||||
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
|
||||
if err := s.DB.
|
||||
Preload("ReadOnlyUsers").
|
||||
Preload("WriteableUsers").
|
||||
Preload("ReadOnlyGroups").
|
||||
Preload("WriteableGroups").
|
||||
Find(&shares).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to retrieve Samba shares: %w", err)
|
||||
}
|
||||
|
||||
@@ -162,10 +402,18 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("failed to fetch dataset for share %s: %v", share.Name, err)
|
||||
}
|
||||
|
||||
if ds == nil {
|
||||
return "", fmt.Errorf("dataset for share %s not found", share.Name)
|
||||
}
|
||||
|
||||
if ds.Mountpoint == "-" || ds.Mountpoint == "" {
|
||||
return "", fmt.Errorf("dataset %s for share %s is not mounted", ds.Name, share.Name)
|
||||
}
|
||||
|
||||
// Best-effort during config generation so a single property-set failure
|
||||
// doesn't prevent Samba from reloading otherwise valid share config.
|
||||
_ = s.ensureSambaDatasetACLProperties(ctx, ds, false)
|
||||
|
||||
datasets[share.Dataset] = ds
|
||||
}
|
||||
}
|
||||
@@ -179,46 +427,65 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
|
||||
|
||||
if share.GuestOk {
|
||||
config.WriteString(fmt.Sprintf("\tguest ok = yes\n"))
|
||||
config.WriteString("\tguest only = yes\n")
|
||||
|
||||
if share.ReadOnly {
|
||||
config.WriteString("\tread only = yes\n")
|
||||
} else {
|
||||
config.WriteString("\tread only = no\n")
|
||||
}
|
||||
} else {
|
||||
config.WriteString(fmt.Sprintf("\tguest ok = no\n"))
|
||||
}
|
||||
|
||||
rGroups := make([]string, 0)
|
||||
wGroups := make([]string, 0)
|
||||
principals := namesFromShareAssociations(share)
|
||||
principals = normalizeSambaPrincipalNames(principals)
|
||||
|
||||
if len(share.ReadOnlyGroups) > 0 {
|
||||
for _, group := range share.ReadOnlyGroups {
|
||||
rGroups = append(rGroups, group.Name)
|
||||
if !share.GuestOk {
|
||||
// Best-effort during config generation to avoid breaking Samba reload.
|
||||
_ = s.syncSambaDatasetPrincipalACLs(dataset.Mountpoint, sambaPrincipalNames{}, principals, false)
|
||||
}
|
||||
|
||||
readUsers := principals.ReadUsers
|
||||
writeUsers := principals.WriteUsers
|
||||
readGroups := principals.ReadGroups
|
||||
writeGroups := principals.WriteGroups
|
||||
|
||||
validUsers := make([]string, 0, len(readUsers)+len(writeUsers)+len(readGroups)+len(writeGroups))
|
||||
validUsers = append(validUsers, readUsers...)
|
||||
validUsers = append(validUsers, writeUsers...)
|
||||
for _, group := range readGroups {
|
||||
validUsers = append(validUsers, "@"+group)
|
||||
}
|
||||
for _, group := range writeGroups {
|
||||
validUsers = append(validUsers, "@"+group)
|
||||
}
|
||||
validUsers = uniquePrincipalNames(validUsers)
|
||||
|
||||
writeList := make([]string, 0, len(writeUsers)+len(writeGroups))
|
||||
writeList = append(writeList, writeUsers...)
|
||||
for _, group := range writeGroups {
|
||||
writeList = append(writeList, "@"+group)
|
||||
}
|
||||
writeList = uniquePrincipalNames(writeList)
|
||||
|
||||
readPrincipalCount := len(readUsers) + len(readGroups)
|
||||
writePrincipalCount := len(writeUsers) + len(writeGroups)
|
||||
|
||||
if !share.GuestOk && len(validUsers) > 0 {
|
||||
config.WriteString(fmt.Sprintf("\tvalid users = %s\n", strings.Join(validUsers, " ")))
|
||||
}
|
||||
|
||||
if !share.GuestOk {
|
||||
if writePrincipalCount == 0 || readPrincipalCount > 0 {
|
||||
config.WriteString("\tread only = yes\n")
|
||||
} else {
|
||||
config.WriteString("\tread only = no\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(share.WriteableGroups) > 0 {
|
||||
for _, group := range share.WriteableGroups {
|
||||
wGroups = append(wGroups, group.Name)
|
||||
}
|
||||
}
|
||||
|
||||
aGroups := utils.JoinStringSlices(rGroups, wGroups)
|
||||
writeList := fmt.Sprintf("%s%s", "@", strings.Join(wGroups, " @"))
|
||||
|
||||
if len(aGroups) > 0 {
|
||||
config.WriteString(fmt.Sprintf("\tvalid users = %s\n", "@"+strings.Join(aGroups, " @")))
|
||||
}
|
||||
|
||||
if share.ReadOnly {
|
||||
config.WriteString("\tread only = yes\n")
|
||||
}
|
||||
|
||||
if share.GuestOk && !share.ReadOnly {
|
||||
config.WriteString("\tforce user = root\n")
|
||||
}
|
||||
|
||||
if len(rGroups) > 0 && len(wGroups) > 0 {
|
||||
config.WriteString("\tread only = yes\n")
|
||||
}
|
||||
|
||||
if len(wGroups) > 0 {
|
||||
config.WriteString(fmt.Sprintf("\twrite list = %s\n", writeList))
|
||||
if !share.GuestOk && len(writeList) > 0 {
|
||||
config.WriteString(fmt.Sprintf("\twrite list = %s\n", strings.Join(writeList, " ")))
|
||||
}
|
||||
|
||||
config.WriteString(fmt.Sprintf("\tcreate mask = %s\n", share.CreateMask))
|
||||
@@ -238,25 +505,6 @@ func (s *Service) ShareConfig(ctx context.Context) (string, error) {
|
||||
config.WriteString("\tfull_audit:log_secdesc = true\n")
|
||||
|
||||
config.WriteString("\n\n")
|
||||
|
||||
_, err := utils.RunCommand("/bin/setfacl", "-b", dataset.Mountpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to clear ACLs on mountpoint %s: %w", dataset.Mountpoint, err)
|
||||
}
|
||||
|
||||
if len(rGroups) > 0 {
|
||||
_, err := utils.RunCommand("/bin/setfacl", "-m", fmt.Sprintf("g:%s:read_set:fd:allow", strings.Join(rGroups, ",")), dataset.Mountpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to set read ACLs for groups %v on mountpoint %s: %w", rGroups, dataset.Mountpoint, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(wGroups) > 0 {
|
||||
_, err := utils.RunCommand("/bin/setfacl", "-m", fmt.Sprintf("g:%s:modify_set:fd:allow", strings.Join(wGroups, ",")), dataset.Mountpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to set write ACLs for groups %v on mountpoint %s: %w", wGroups, dataset.Mountpoint, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config.String(), nil
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
//
|
||||
// Copyright (c) 2025 The FreeBSD Foundation.
|
||||
//
|
||||
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
|
||||
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
|
||||
// under sponsorship from the FreeBSD Foundation.
|
||||
|
||||
package samba
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alchemillahq/gzfs"
|
||||
gzfstest "github.com/alchemillahq/gzfs/testutil"
|
||||
"github.com/alchemillahq/sylve/internal/db/models"
|
||||
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
|
||||
"github.com/alchemillahq/sylve/internal/testutil"
|
||||
)
|
||||
|
||||
type mockDataset struct {
|
||||
Name string
|
||||
GUID string
|
||||
Mountpoint string
|
||||
}
|
||||
|
||||
func newSambaServiceWithMockRunner(t *testing.T) (*Service, *gzfstest.MockRunner) {
|
||||
t.Helper()
|
||||
|
||||
dbConn := testutil.NewSQLiteTestDB(
|
||||
t,
|
||||
&models.Group{},
|
||||
&sambaModels.SambaSettings{},
|
||||
&sambaModels.SambaShare{},
|
||||
)
|
||||
|
||||
runner := gzfstest.NewMockRunner()
|
||||
client := gzfs.NewClient(gzfs.Options{
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
return &Service{
|
||||
DB: dbConn,
|
||||
GZFS: client,
|
||||
}, runner
|
||||
}
|
||||
|
||||
func addDatasetLookupMocks(t *testing.T, runner *gzfstest.MockRunner, datasets []mockDataset) {
|
||||
t.Helper()
|
||||
|
||||
makeResp := func() string {
|
||||
resp := map[string]any{
|
||||
"output_version": map[string]any{
|
||||
"command": "zfs",
|
||||
"vers_major": 0,
|
||||
"vers_minor": 0,
|
||||
},
|
||||
"datasets": map[string]any{},
|
||||
}
|
||||
|
||||
dsMap := resp["datasets"].(map[string]any)
|
||||
for _, ds := range datasets {
|
||||
pool := ds.Name
|
||||
if idx := strings.Index(ds.Name, "/"); idx > 0 {
|
||||
pool = ds.Name[:idx]
|
||||
}
|
||||
|
||||
dsMap[ds.Name] = map[string]any{
|
||||
"name": ds.Name,
|
||||
"type": string(gzfs.DatasetTypeFilesystem),
|
||||
"pool": pool,
|
||||
"properties": map[string]any{
|
||||
"guid": map[string]any{
|
||||
"value": ds.GUID,
|
||||
"source": map[string]any{
|
||||
"type": "local",
|
||||
"data": "-",
|
||||
},
|
||||
},
|
||||
"mountpoint": map[string]any{
|
||||
"value": ds.Mountpoint,
|
||||
"source": map[string]any{
|
||||
"type": "local",
|
||||
"data": "-",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal mock zfs response: %v", err)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
resp := makeResp()
|
||||
runner.AddCommand("zfs get -p -H -o name,value guid -j", resp, "", nil)
|
||||
runner.AddCommand("zfs list -o name,origin,used,available,recordsize,mountpoint,compression,type,volsize,quota,referenced,written,logicalused,usedbydataset,guid,mounted,checksum,aclmode,aclinherit,primarycache,volmode,compressratio,atime,dedup,volblocksize,encryption,encryptionroot,keyformat,keylocation -p", resp, "", nil)
|
||||
}
|
||||
|
||||
func TestGlobalConfigMapToGuestIsConditional(t *testing.T) {
|
||||
svc, _ := newSambaServiceWithMockRunner(t)
|
||||
|
||||
settings := sambaModels.SambaSettings{
|
||||
UnixCharset: "UTF-8",
|
||||
Workgroup: "WORKGROUP",
|
||||
ServerString: "Sylve SMB Server",
|
||||
Interfaces: "lo0",
|
||||
BindInterfacesOnly: true,
|
||||
}
|
||||
if err := svc.DB.Create(&settings).Error; err != nil {
|
||||
t.Fatalf("failed creating samba settings: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := svc.GlobalConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalConfig failed: %v", err)
|
||||
}
|
||||
if strings.Contains(cfg, "map to guest = Bad User") {
|
||||
t.Fatalf("did not expect map to guest when there are no guest-only shares")
|
||||
}
|
||||
|
||||
regular := sambaModels.SambaShare{
|
||||
Name: "regular",
|
||||
Dataset: "guid-regular",
|
||||
GuestOk: false,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(®ular).Error; err != nil {
|
||||
t.Fatalf("failed creating regular share: %v", err)
|
||||
}
|
||||
|
||||
cfg, err = svc.GlobalConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalConfig failed: %v", err)
|
||||
}
|
||||
if strings.Contains(cfg, "map to guest = Bad User") {
|
||||
t.Fatalf("did not expect map to guest when there are only authenticated shares")
|
||||
}
|
||||
|
||||
guest := sambaModels.SambaShare{
|
||||
Name: "guest",
|
||||
Dataset: "guid-guest",
|
||||
GuestOk: true,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(&guest).Error; err != nil {
|
||||
t.Fatalf("failed creating guest share: %v", err)
|
||||
}
|
||||
|
||||
cfg, err = svc.GlobalConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalConfig failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(cfg, "map to guest = Bad User") {
|
||||
t.Fatalf("expected map to guest when at least one guest-only share exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareConfigGuestOnlyDoesNotEmitUserLists(t *testing.T) {
|
||||
svc, runner := newSambaServiceWithMockRunner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
share := sambaModels.SambaShare{
|
||||
Name: "public",
|
||||
Dataset: "guid-public",
|
||||
GuestOk: true,
|
||||
ReadOnly: false,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(&share).Error; err != nil {
|
||||
t.Fatalf("failed creating share: %v", err)
|
||||
}
|
||||
|
||||
addDatasetLookupMocks(t, runner, []mockDataset{
|
||||
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
|
||||
})
|
||||
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "", nil)
|
||||
|
||||
cfg, err := svc.ShareConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ShareConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(cfg, "[public]") {
|
||||
t.Fatalf("expected share section for public")
|
||||
}
|
||||
if !strings.Contains(cfg, "\tguest ok = yes\n") {
|
||||
t.Fatalf("expected guest ok = yes")
|
||||
}
|
||||
if !strings.Contains(cfg, "\tguest only = yes\n") {
|
||||
t.Fatalf("expected guest only = yes")
|
||||
}
|
||||
if !strings.Contains(cfg, "\tread only = no\n") {
|
||||
t.Fatalf("expected guest-only writable share to emit read only = no")
|
||||
}
|
||||
if strings.Contains(cfg, "valid users =") {
|
||||
t.Fatalf("did not expect valid users for guest-only share")
|
||||
}
|
||||
if strings.Contains(cfg, "write list =") {
|
||||
t.Fatalf("did not expect write list for guest-only share")
|
||||
}
|
||||
if strings.Contains(cfg, "force user =") {
|
||||
t.Fatalf("did not expect force user for guest-only share")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareConfigAuthenticatedEmitsAccessLists(t *testing.T) {
|
||||
svc, runner := newSambaServiceWithMockRunner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ro := models.Group{Name: "ro"}
|
||||
rw := models.Group{Name: "rw"}
|
||||
if err := svc.DB.Create(&ro).Error; err != nil {
|
||||
t.Fatalf("failed creating ro group: %v", err)
|
||||
}
|
||||
if err := svc.DB.Create(&rw).Error; err != nil {
|
||||
t.Fatalf("failed creating rw group: %v", err)
|
||||
}
|
||||
|
||||
share := sambaModels.SambaShare{
|
||||
Name: "secure",
|
||||
Dataset: "guid-secure",
|
||||
GuestOk: false,
|
||||
ReadOnly: false,
|
||||
ReadOnlyGroups: []models.Group{ro},
|
||||
WriteableGroups: []models.Group{rw},
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(&share).Error; err != nil {
|
||||
t.Fatalf("failed creating share: %v", err)
|
||||
}
|
||||
|
||||
addDatasetLookupMocks(t, runner, []mockDataset{
|
||||
{Name: "tank/secure", GUID: "guid-secure", Mountpoint: "/mnt/secure"},
|
||||
})
|
||||
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/secure", "", "", nil)
|
||||
|
||||
cfg, err := svc.ShareConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ShareConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(cfg, "\tguest ok = no\n") {
|
||||
t.Fatalf("expected guest ok = no for authenticated share")
|
||||
}
|
||||
if !strings.Contains(cfg, "valid users = @ro @rw") {
|
||||
t.Fatalf("expected valid users for authenticated share, got:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "write list = @rw") {
|
||||
t.Fatalf("expected write list for authenticated share, got:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "\tread only = yes\n") {
|
||||
t.Fatalf("expected read only = yes for split read/write groups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareConfigBestEffortWhenACLPropertySetFails(t *testing.T) {
|
||||
svc, runner := newSambaServiceWithMockRunner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
share := sambaModels.SambaShare{
|
||||
Name: "public",
|
||||
Dataset: "guid-public",
|
||||
GuestOk: true,
|
||||
ReadOnly: false,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(&share).Error; err != nil {
|
||||
t.Fatalf("failed creating share: %v", err)
|
||||
}
|
||||
|
||||
addDatasetLookupMocks(t, runner, []mockDataset{
|
||||
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
|
||||
})
|
||||
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "failed", errors.New("set failed"))
|
||||
|
||||
cfg, err := svc.ShareConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ShareConfig should not fail when ACL property set fails in best-effort mode: %v", err)
|
||||
}
|
||||
if !strings.Contains(cfg, "[public]") {
|
||||
t.Fatalf("expected share config to still be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareRejectsGuestOnlyWithPrincipals(t *testing.T) {
|
||||
svc, _ := newSambaServiceWithMockRunner(t)
|
||||
|
||||
err := svc.CreateShare(
|
||||
context.Background(),
|
||||
"public",
|
||||
"guid-public",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]uint{1},
|
||||
true,
|
||||
false,
|
||||
"0664",
|
||||
"2775",
|
||||
false,
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for guest-only share with principals")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "guest_only_share_cannot_have_principals") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShareRejectsGuestOnlyWithPrincipals(t *testing.T) {
|
||||
svc, _ := newSambaServiceWithMockRunner(t)
|
||||
|
||||
existing := sambaModels.SambaShare{
|
||||
Name: "private",
|
||||
Dataset: "guid-private",
|
||||
GuestOk: false,
|
||||
CreateMask: "0664",
|
||||
DirectoryMask: "2775",
|
||||
}
|
||||
if err := svc.DB.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("failed creating existing share: %v", err)
|
||||
}
|
||||
|
||||
err := svc.UpdateShare(
|
||||
context.Background(),
|
||||
uint(existing.ID),
|
||||
existing.Name,
|
||||
existing.Dataset,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
[]uint{1},
|
||||
true,
|
||||
false,
|
||||
"0664",
|
||||
"2775",
|
||||
false,
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for guest-only share with principals")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "guest_only_share_cannot_have_principals") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareFailsWhenACLPropertyEnforcementFails(t *testing.T) {
|
||||
svc, runner := newSambaServiceWithMockRunner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
addDatasetLookupMocks(t, runner, []mockDataset{
|
||||
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
|
||||
})
|
||||
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "failed", errors.New("set failed"))
|
||||
|
||||
err := svc.CreateShare(
|
||||
ctx,
|
||||
"public",
|
||||
"guid-public",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
"0664",
|
||||
"2775",
|
||||
false,
|
||||
0,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected ACL enforcement failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed_to_enforce_samba_dataset_acl_properties") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShareWriteWinsForOverlappingGroupPermissions(t *testing.T) {
|
||||
if _, err := os.Stat("/bin/setfacl"); err != nil {
|
||||
t.Skip("setfacl is not available on this test host")
|
||||
}
|
||||
|
||||
svc, runner := newSambaServiceWithMockRunner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
group := models.Group{Name: "staff"}
|
||||
if err := svc.DB.Create(&group).Error; err != nil {
|
||||
t.Fatalf("failed creating group: %v", err)
|
||||
}
|
||||
|
||||
addDatasetLookupMocks(t, runner, []mockDataset{
|
||||
{Name: "tank/public", GUID: "guid-public", Mountpoint: "/mnt/public"},
|
||||
})
|
||||
runner.AddCommand("zfs set acltype=nfsv4 aclmode=restricted aclinherit=passthrough tank/public", "", "", nil)
|
||||
runner.AddCommand("/bin/setfacl -m g:staff:modify_set:fd:allow /mnt/public", "", "", nil)
|
||||
|
||||
err := svc.CreateShare(
|
||||
ctx,
|
||||
"public",
|
||||
"guid-public",
|
||||
nil,
|
||||
nil,
|
||||
[]uint{group.ID},
|
||||
[]uint{group.ID},
|
||||
false,
|
||||
false,
|
||||
"0664",
|
||||
"2775",
|
||||
false,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateShare failed: %v", err)
|
||||
}
|
||||
|
||||
var share sambaModels.SambaShare
|
||||
if err := svc.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").First(&share).Error; err != nil {
|
||||
t.Fatalf("failed loading created share: %v", err)
|
||||
}
|
||||
|
||||
if len(share.ReadOnlyGroups) != 0 {
|
||||
t.Fatalf("expected overlapping read group to be removed, got %d read groups", len(share.ReadOnlyGroups))
|
||||
}
|
||||
if len(share.WriteableGroups) != 1 || share.WriteableGroups[0].ID != group.ID {
|
||||
t.Fatalf("expected write group to be retained after normalization")
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,243 @@ package samba
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/alchemillahq/sylve/internal/db/models"
|
||||
sambaModels "github.com/alchemillahq/sylve/internal/db/models/samba"
|
||||
"github.com/alchemillahq/sylve/pkg/utils"
|
||||
"github.com/alchemillahq/sylve/internal/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type sambaPermissionIDs struct {
|
||||
ReadUserIDs []uint
|
||||
WriteUserIDs []uint
|
||||
ReadGroupIDs []uint
|
||||
WriteGroupIDs []uint
|
||||
}
|
||||
|
||||
type sambaPrincipalNames struct {
|
||||
ReadUsers []string
|
||||
WriteUsers []string
|
||||
ReadGroups []string
|
||||
WriteGroups []string
|
||||
}
|
||||
|
||||
func uniqueUint(ids []uint) []uint {
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
out := make([]uint, 0, len(ids))
|
||||
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSambaPermissionIDs(
|
||||
readUserIDs []uint,
|
||||
writeUserIDs []uint,
|
||||
readGroupIDs []uint,
|
||||
writeGroupIDs []uint,
|
||||
) sambaPermissionIDs {
|
||||
normalized := sambaPermissionIDs{
|
||||
ReadUserIDs: uniqueUint(readUserIDs),
|
||||
WriteUserIDs: uniqueUint(writeUserIDs),
|
||||
ReadGroupIDs: uniqueUint(readGroupIDs),
|
||||
WriteGroupIDs: uniqueUint(writeGroupIDs),
|
||||
}
|
||||
|
||||
writeUsers := make(map[uint]struct{}, len(normalized.WriteUserIDs))
|
||||
for _, id := range normalized.WriteUserIDs {
|
||||
writeUsers[id] = struct{}{}
|
||||
}
|
||||
|
||||
filteredReadUsers := make([]uint, 0, len(normalized.ReadUserIDs))
|
||||
for _, id := range normalized.ReadUserIDs {
|
||||
if _, exists := writeUsers[id]; exists {
|
||||
continue
|
||||
}
|
||||
filteredReadUsers = append(filteredReadUsers, id)
|
||||
}
|
||||
normalized.ReadUserIDs = filteredReadUsers
|
||||
|
||||
writeGroups := make(map[uint]struct{}, len(normalized.WriteGroupIDs))
|
||||
for _, id := range normalized.WriteGroupIDs {
|
||||
writeGroups[id] = struct{}{}
|
||||
}
|
||||
|
||||
filteredReadGroups := make([]uint, 0, len(normalized.ReadGroupIDs))
|
||||
for _, id := range normalized.ReadGroupIDs {
|
||||
if _, exists := writeGroups[id]; exists {
|
||||
continue
|
||||
}
|
||||
filteredReadGroups = append(filteredReadGroups, id)
|
||||
}
|
||||
normalized.ReadGroupIDs = filteredReadGroups
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (ids sambaPermissionIDs) principalCount() int {
|
||||
return len(ids.ReadUserIDs) + len(ids.WriteUserIDs) + len(ids.ReadGroupIDs) + len(ids.WriteGroupIDs)
|
||||
}
|
||||
|
||||
func collectMissingIDs(expected []uint, present map[uint]struct{}) []uint {
|
||||
missing := make([]uint, 0)
|
||||
|
||||
for _, id := range expected {
|
||||
if _, exists := present[id]; exists {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, id)
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
func usersByIDs(users []models.User) map[uint]models.User {
|
||||
byID := make(map[uint]models.User, len(users))
|
||||
for _, user := range users {
|
||||
byID[user.ID] = user
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
func groupsByIDs(groups []models.Group) map[uint]models.Group {
|
||||
byID := make(map[uint]models.Group, len(groups))
|
||||
for _, group := range groups {
|
||||
byID[group.ID] = group
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
func usersForIDs(ids []uint, byID map[uint]models.User) []models.User {
|
||||
out := make([]models.User, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if user, exists := byID[id]; exists {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupsForIDs(ids []uint, byID map[uint]models.Group) []models.Group {
|
||||
out := make([]models.Group, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if group, exists := byID[id]; exists {
|
||||
out = append(out, group)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func usernames(users []models.User) []string {
|
||||
names := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
names = append(names, user.Username)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func groupNames(groups []models.Group) []string {
|
||||
names := make([]string, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
names = append(names, group.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Service) loadUsersAndGroupsByIDs(
|
||||
readUserIDs []uint,
|
||||
writeUserIDs []uint,
|
||||
readGroupIDs []uint,
|
||||
writeGroupIDs []uint,
|
||||
) ([]models.User, []models.User, []models.Group, []models.Group, error) {
|
||||
allUserIDs := uniqueUint(append(append([]uint{}, readUserIDs...), writeUserIDs...))
|
||||
allGroupIDs := uniqueUint(append(append([]uint{}, readGroupIDs...), writeGroupIDs...))
|
||||
|
||||
var users []models.User
|
||||
if len(allUserIDs) > 0 {
|
||||
if err := s.DB.Where("id IN ?", allUserIDs).Find(&users).Error; err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed_to_fetch_users: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
foundUsers := make(map[uint]struct{}, len(users))
|
||||
for _, user := range users {
|
||||
foundUsers[user.ID] = struct{}{}
|
||||
}
|
||||
if missing := collectMissingIDs(allUserIDs, foundUsers); len(missing) > 0 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("user_not_found: %d", missing[0])
|
||||
}
|
||||
|
||||
var groups []models.Group
|
||||
if len(allGroupIDs) > 0 {
|
||||
if err := s.DB.Where("id IN ?", allGroupIDs).Find(&groups).Error; err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed_to_fetch_groups: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
foundGroups := make(map[uint]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
foundGroups[group.ID] = struct{}{}
|
||||
}
|
||||
if missing := collectMissingIDs(allGroupIDs, foundGroups); len(missing) > 0 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("group_not_found: %d", missing[0])
|
||||
}
|
||||
|
||||
usersByID := usersByIDs(users)
|
||||
groupsByID := groupsByIDs(groups)
|
||||
|
||||
readUsers := usersForIDs(readUserIDs, usersByID)
|
||||
writeUsers := usersForIDs(writeUserIDs, usersByID)
|
||||
readGroups := groupsForIDs(readGroupIDs, groupsByID)
|
||||
writeGroups := groupsForIDs(writeGroupIDs, groupsByID)
|
||||
|
||||
return readUsers, writeUsers, readGroups, writeGroups, nil
|
||||
}
|
||||
|
||||
func namesFromShareAssociations(share sambaModels.SambaShare) sambaPrincipalNames {
|
||||
return sambaPrincipalNames{
|
||||
ReadUsers: usernames(share.ReadOnlyUsers),
|
||||
WriteUsers: usernames(share.WriteableUsers),
|
||||
ReadGroups: groupNames(share.ReadOnlyGroups),
|
||||
WriteGroups: groupNames(share.WriteableGroups),
|
||||
}
|
||||
}
|
||||
|
||||
func namesFromACLPrincipals(
|
||||
readUsers []models.User,
|
||||
writeUsers []models.User,
|
||||
readGroups []models.Group,
|
||||
writeGroups []models.Group,
|
||||
) sambaPrincipalNames {
|
||||
return sambaPrincipalNames{
|
||||
ReadUsers: usernames(readUsers),
|
||||
WriteUsers: usernames(writeUsers),
|
||||
ReadGroups: groupNames(readGroups),
|
||||
WriteGroups: groupNames(writeGroups),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetShares() ([]sambaModels.SambaShare, error) {
|
||||
var shares []sambaModels.SambaShare
|
||||
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").Find(&shares).Error; err != nil {
|
||||
if err := s.DB.
|
||||
Preload("ReadOnlyUsers").
|
||||
Preload("WriteableUsers").
|
||||
Preload("ReadOnlyGroups").
|
||||
Preload("WriteableGroups").
|
||||
Find(&shares).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed_to_get_shares: %w", err)
|
||||
}
|
||||
return shares, nil
|
||||
@@ -29,24 +256,37 @@ func (s *Service) CreateShare(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
dataset string,
|
||||
readOnlyGroups []string,
|
||||
writeableGroups []string,
|
||||
readUserIDs []uint,
|
||||
writeUserIDs []uint,
|
||||
readGroupIDs []uint,
|
||||
writeGroupIDs []uint,
|
||||
guestEnabled bool,
|
||||
guestWriteable bool,
|
||||
createMask string,
|
||||
directoryMask string,
|
||||
guestOk bool,
|
||||
readOnly bool,
|
||||
timeMachine bool,
|
||||
timeMachineMaxSize uint64) error {
|
||||
timeMachineMaxSize uint64,
|
||||
) error {
|
||||
if err := s.DB.Where("name = ?", name).First(&sambaModels.SambaShare{}).Error; err == nil {
|
||||
return fmt.Errorf("share_with_name_exists")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed_to_check_name_conflict: %w", err)
|
||||
}
|
||||
|
||||
if err := s.DB.Where("dataset = ?", dataset).First(&sambaModels.SambaShare{}).Error; err == nil {
|
||||
return fmt.Errorf("share_with_dataset_exists")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed_to_check_dataset_conflict: %w", err)
|
||||
}
|
||||
|
||||
if len(readOnlyGroups) > 0 && readOnly {
|
||||
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
|
||||
normalized := normalizeSambaPermissionIDs(readUserIDs, writeUserIDs, readGroupIDs, writeGroupIDs)
|
||||
|
||||
if guestEnabled && normalized.principalCount() > 0 {
|
||||
return fmt.Errorf("guest_only_share_cannot_have_principals")
|
||||
}
|
||||
|
||||
if !guestEnabled && normalized.principalCount() == 0 {
|
||||
return fmt.Errorf("no_principals_selected_and_guests_not_allowed")
|
||||
}
|
||||
|
||||
fDataset, err := s.GZFS.ZFS.GetByGUID(ctx, dataset, false)
|
||||
@@ -58,58 +298,85 @@ func (s *Service) CreateShare(
|
||||
return fmt.Errorf("dataset_not_found")
|
||||
}
|
||||
|
||||
if fDataset.Mountpoint == "" {
|
||||
if fDataset.Mountpoint == "" || fDataset.Mountpoint == "-" {
|
||||
return fmt.Errorf("dataset_not_mounted")
|
||||
}
|
||||
|
||||
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
|
||||
|
||||
if len(allGroups) == 0 && !guestOk {
|
||||
return fmt.Errorf("no_groups_selected_and_guests_not_allowed")
|
||||
if err := s.ensureSambaDatasetACLProperties(ctx, fDataset, true); err != nil {
|
||||
return fmt.Errorf("failed_to_enforce_samba_dataset_acl_properties: %w", err)
|
||||
}
|
||||
|
||||
for _, group := range allGroups {
|
||||
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
|
||||
return fmt.Errorf("group_not_found: %s", group)
|
||||
}
|
||||
readUsers, writeUsers, readGroups, writeGroups, err := s.loadUsersAndGroupsByIDs(
|
||||
normalized.ReadUserIDs,
|
||||
normalized.WriteUserIDs,
|
||||
normalized.ReadGroupIDs,
|
||||
normalized.WriteGroupIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roGroups []models.Group
|
||||
var wrGroups []models.Group
|
||||
|
||||
for _, group := range readOnlyGroups {
|
||||
var g models.Group
|
||||
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
|
||||
return fmt.Errorf("read_only_group_not_found: %s", group)
|
||||
desiredPrincipals := namesFromACLPrincipals(readUsers, writeUsers, readGroups, writeGroups)
|
||||
if !guestEnabled {
|
||||
if err := s.syncSambaDatasetPrincipalACLs(
|
||||
fDataset.Mountpoint,
|
||||
sambaPrincipalNames{},
|
||||
desiredPrincipals,
|
||||
true,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
|
||||
}
|
||||
roGroups = append(roGroups, g)
|
||||
}
|
||||
|
||||
for _, group := range writeableGroups {
|
||||
var g models.Group
|
||||
if err := s.DB.Where("name = ?", group).First(&g).Error; err != nil {
|
||||
return fmt.Errorf("writeable_group_not_found: %s", group)
|
||||
}
|
||||
wrGroups = append(wrGroups, g)
|
||||
}
|
||||
|
||||
share := sambaModels.SambaShare{
|
||||
Name: name,
|
||||
Dataset: dataset,
|
||||
ReadOnlyGroups: roGroups,
|
||||
WriteableGroups: wrGroups,
|
||||
CreateMask: createMask,
|
||||
DirectoryMask: directoryMask,
|
||||
GuestOk: guestOk,
|
||||
ReadOnly: readOnly,
|
||||
GuestOk: guestEnabled,
|
||||
ReadOnly: !guestWriteable && guestEnabled,
|
||||
TimeMachine: timeMachine,
|
||||
TimeMachineMaxSize: timeMachineMaxSize,
|
||||
}
|
||||
|
||||
if err := s.DB.Create(&share).Error; err != nil {
|
||||
tx := s.DB.Begin()
|
||||
if err := tx.Create(&share).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_create_share: %w", err)
|
||||
}
|
||||
|
||||
if len(readUsers) > 0 {
|
||||
if err := tx.Model(&share).Association("ReadOnlyUsers").Append(readUsers); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_read_only_users: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeUsers) > 0 {
|
||||
if err := tx.Model(&share).Association("WriteableUsers").Append(writeUsers); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_writeable_users: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(readGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(readGroups); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_read_only_groups: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("WriteableGroups").Append(writeGroups); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_writeable_groups: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("failed_to_commit_transaction: %w", err)
|
||||
}
|
||||
|
||||
return s.WriteConfig(ctx, true)
|
||||
}
|
||||
|
||||
@@ -118,17 +385,24 @@ func (s *Service) UpdateShare(
|
||||
id uint,
|
||||
name string,
|
||||
dataset string,
|
||||
readOnlyGroups []string,
|
||||
writeableGroups []string,
|
||||
readUserIDs []uint,
|
||||
writeUserIDs []uint,
|
||||
readGroupIDs []uint,
|
||||
writeGroupIDs []uint,
|
||||
guestEnabled bool,
|
||||
guestWriteable bool,
|
||||
createMask string,
|
||||
directoryMask string,
|
||||
guestOk bool,
|
||||
readOnly bool,
|
||||
timeMachine bool,
|
||||
timeMachineMaxSize uint64,
|
||||
) error {
|
||||
var share sambaModels.SambaShare
|
||||
if err := s.DB.Preload("ReadOnlyGroups").Preload("WriteableGroups").First(&share, id).Error; err != nil {
|
||||
if err := s.DB.
|
||||
Preload("ReadOnlyUsers").
|
||||
Preload("WriteableUsers").
|
||||
Preload("ReadOnlyGroups").
|
||||
Preload("WriteableGroups").
|
||||
First(&share, id).Error; err != nil {
|
||||
return fmt.Errorf("share_not_found: %w", err)
|
||||
}
|
||||
|
||||
@@ -156,8 +430,14 @@ func (s *Service) UpdateShare(
|
||||
}
|
||||
}
|
||||
|
||||
if len(readOnlyGroups) > 0 && readOnly {
|
||||
return fmt.Errorf("cannot_create_read_only_share_with_read_only_groups")
|
||||
normalized := normalizeSambaPermissionIDs(readUserIDs, writeUserIDs, readGroupIDs, writeGroupIDs)
|
||||
|
||||
if guestEnabled && normalized.principalCount() > 0 {
|
||||
return fmt.Errorf("guest_only_share_cannot_have_principals")
|
||||
}
|
||||
|
||||
if !guestEnabled && normalized.principalCount() == 0 {
|
||||
return fmt.Errorf("no_principals_selected_and_guests_not_allowed")
|
||||
}
|
||||
|
||||
fDataset, err := s.GZFS.ZFS.GetByGUID(ctx, dataset, false)
|
||||
@@ -169,42 +449,78 @@ func (s *Service) UpdateShare(
|
||||
return fmt.Errorf("dataset_not_found")
|
||||
}
|
||||
|
||||
if fDataset.Mountpoint == "" {
|
||||
if fDataset.Mountpoint == "" || fDataset.Mountpoint == "-" {
|
||||
return fmt.Errorf("dataset_not_mounted")
|
||||
}
|
||||
|
||||
allGroups := utils.JoinStringSlices(readOnlyGroups, writeableGroups)
|
||||
|
||||
if len(allGroups) == 0 && !guestOk {
|
||||
return fmt.Errorf("no_groups_selected_and_guests_not_allowed")
|
||||
if err := s.ensureSambaDatasetACLProperties(ctx, fDataset, true); err != nil {
|
||||
return fmt.Errorf("failed_to_enforce_samba_dataset_acl_properties: %w", err)
|
||||
}
|
||||
|
||||
for _, group := range allGroups {
|
||||
if err := s.DB.Where("name = ?", group).First(&models.Group{}).Error; err != nil {
|
||||
return fmt.Errorf("group_not_found: %s", group)
|
||||
}
|
||||
readUsers, writeUsers, readGroups, writeGroups, err := s.loadUsersAndGroupsByIDs(
|
||||
normalized.ReadUserIDs,
|
||||
normalized.WriteUserIDs,
|
||||
normalized.ReadGroupIDs,
|
||||
normalized.WriteGroupIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var roGroups, wrGroups []models.Group
|
||||
|
||||
for _, gname := range readOnlyGroups {
|
||||
var g models.Group
|
||||
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
|
||||
return fmt.Errorf("read_only_group_not_found: %s", gname)
|
||||
}
|
||||
roGroups = append(roGroups, g)
|
||||
previousPrincipals := namesFromShareAssociations(share)
|
||||
desiredPrincipals := sambaPrincipalNames{}
|
||||
if !guestEnabled {
|
||||
desiredPrincipals = namesFromACLPrincipals(readUsers, writeUsers, readGroups, writeGroups)
|
||||
}
|
||||
|
||||
for _, gname := range writeableGroups {
|
||||
var g models.Group
|
||||
if err := s.DB.Where("name = ?", gname).First(&g).Error; err != nil {
|
||||
return fmt.Errorf("writeable_group_not_found: %s", gname)
|
||||
if dataset == share.Dataset {
|
||||
if err := s.syncSambaDatasetPrincipalACLs(
|
||||
fDataset.Mountpoint,
|
||||
previousPrincipals,
|
||||
desiredPrincipals,
|
||||
true,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
|
||||
}
|
||||
} else {
|
||||
oldDataset, oldDatasetErr := s.GZFS.ZFS.GetByGUID(ctx, share.Dataset, false)
|
||||
if oldDatasetErr != nil {
|
||||
return fmt.Errorf("failed_to_fetch_previous_dataset: %v", oldDatasetErr)
|
||||
}
|
||||
|
||||
if oldDataset != nil && oldDataset.Mountpoint != "" && oldDataset.Mountpoint != "-" {
|
||||
if err := s.syncSambaDatasetPrincipalACLs(
|
||||
oldDataset.Mountpoint,
|
||||
previousPrincipals,
|
||||
sambaPrincipalNames{},
|
||||
true,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed_to_cleanup_previous_samba_dataset_principal_acls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.syncSambaDatasetPrincipalACLs(
|
||||
fDataset.Mountpoint,
|
||||
sambaPrincipalNames{},
|
||||
desiredPrincipals,
|
||||
true,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed_to_enforce_samba_dataset_principal_acls: %w", err)
|
||||
}
|
||||
wrGroups = append(wrGroups, g)
|
||||
}
|
||||
|
||||
tx := s.DB.Begin()
|
||||
|
||||
if err := tx.Model(&share).Association("ReadOnlyUsers").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_read_only_users: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&share).Association("WriteableUsers").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_writeable_users: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&share).Association("ReadOnlyGroups").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_read_only_groups: %w", err)
|
||||
@@ -219,8 +535,8 @@ func (s *Service) UpdateShare(
|
||||
share.Dataset = dataset
|
||||
share.CreateMask = createMask
|
||||
share.DirectoryMask = directoryMask
|
||||
share.GuestOk = guestOk
|
||||
share.ReadOnly = readOnly
|
||||
share.GuestOk = guestEnabled
|
||||
share.ReadOnly = !guestWriteable && guestEnabled
|
||||
share.TimeMachine = timeMachine
|
||||
share.TimeMachineMaxSize = timeMachineMaxSize
|
||||
|
||||
@@ -229,14 +545,29 @@ func (s *Service) UpdateShare(
|
||||
return fmt.Errorf("failed_to_update_share_fields: %w", err)
|
||||
}
|
||||
|
||||
if len(roGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(roGroups); err != nil {
|
||||
if len(readUsers) > 0 {
|
||||
if err := tx.Model(&share).Association("ReadOnlyUsers").Append(readUsers); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_read_only_users: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeUsers) > 0 {
|
||||
if err := tx.Model(&share).Association("WriteableUsers").Append(writeUsers); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_writeable_users: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(readGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("ReadOnlyGroups").Append(readGroups); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_read_only_groups: %w", err)
|
||||
}
|
||||
}
|
||||
if len(wrGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("WriteableGroups").Append(wrGroups); err != nil {
|
||||
|
||||
if len(writeGroups) > 0 {
|
||||
if err := tx.Model(&share).Association("WriteableGroups").Append(writeGroups); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_append_writeable_groups: %w", err)
|
||||
}
|
||||
@@ -251,13 +582,53 @@ func (s *Service) UpdateShare(
|
||||
|
||||
func (s *Service) DeleteShare(ctx context.Context, id uint) error {
|
||||
var share sambaModels.SambaShare
|
||||
if err := s.DB.Where("id = ?", id).First(&share).Error; err != nil {
|
||||
if err := s.DB.
|
||||
Preload("ReadOnlyUsers").
|
||||
Preload("WriteableUsers").
|
||||
Preload("ReadOnlyGroups").
|
||||
Preload("WriteableGroups").
|
||||
First(&share, id).Error; err != nil {
|
||||
return fmt.Errorf("share_not_found: %w", err)
|
||||
}
|
||||
|
||||
if err := s.DB.Delete(&share).Error; err != nil {
|
||||
previousPrincipals := namesFromShareAssociations(share)
|
||||
dataset, err := s.GZFS.ZFS.GetByGUID(ctx, share.Dataset, false)
|
||||
if err != nil {
|
||||
logger.L.Warn().Err(err).Int("share_id", share.ID).Msg("failed to fetch dataset while cleaning samba ACL principals")
|
||||
} else if dataset != nil && dataset.Mountpoint != "" && dataset.Mountpoint != "-" {
|
||||
_ = s.syncSambaDatasetPrincipalACLs(dataset.Mountpoint, previousPrincipals, sambaPrincipalNames{}, false)
|
||||
}
|
||||
|
||||
tx := s.DB.Begin()
|
||||
|
||||
if err := tx.Model(&share).Association("ReadOnlyUsers").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_read_only_users: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&share).Association("WriteableUsers").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_writeable_users: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&share).Association("ReadOnlyGroups").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_read_only_groups: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&share).Association("WriteableGroups").Clear(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_clear_writeable_groups: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Delete(&share).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed_to_delete_share: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("failed_to_commit_transaction: %w", err)
|
||||
}
|
||||
|
||||
return s.WriteConfig(ctx, true)
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ func TestCreateShareReturnsDatasetConflictBeforeDBDuplicate(t *testing.T) {
|
||||
"dataset-guid-1",
|
||||
nil,
|
||||
nil,
|
||||
"0664",
|
||||
"2775",
|
||||
nil,
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
"0664",
|
||||
"2775",
|
||||
false,
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -10,22 +10,26 @@ export async function getSambaShares(): Promise<SambaShare[]> {
|
||||
export async function createSambaShare(
|
||||
name: string,
|
||||
dataset: string,
|
||||
readOnlyGroups: string[] = [],
|
||||
writeableGroups: string[] = [],
|
||||
permissions: {
|
||||
read: { userIds: number[]; groupIds: number[] };
|
||||
write: { userIds: number[]; groupIds: number[] };
|
||||
},
|
||||
guest: {
|
||||
enabled: boolean;
|
||||
writeable: boolean;
|
||||
},
|
||||
createMask: string = '',
|
||||
directoryMask: string = '',
|
||||
guestOk: boolean = false,
|
||||
timeMachine: boolean = false,
|
||||
timeMachineMaxSize: number = 0
|
||||
): Promise<APIResponse> {
|
||||
return await apiRequest('/samba/shares', APIResponseSchema, 'POST', {
|
||||
name,
|
||||
dataset,
|
||||
readOnlyGroups,
|
||||
writeableGroups,
|
||||
permissions,
|
||||
guest,
|
||||
createMask,
|
||||
directoryMask,
|
||||
guestOk,
|
||||
timeMachine,
|
||||
timeMachineMaxSize
|
||||
});
|
||||
@@ -35,12 +39,16 @@ export async function updateSambaShare(
|
||||
id: number,
|
||||
name: string,
|
||||
dataset: string,
|
||||
readOnlyGroups: string[] = [],
|
||||
writeableGroups: string[] = [],
|
||||
permissions: {
|
||||
read: { userIds: number[]; groupIds: number[] };
|
||||
write: { userIds: number[]; groupIds: number[] };
|
||||
},
|
||||
guest: {
|
||||
enabled: boolean;
|
||||
writeable: boolean;
|
||||
},
|
||||
createMask: string = '',
|
||||
directoryMask: string = '',
|
||||
guestOk: boolean = false,
|
||||
readOnly: boolean = false,
|
||||
timeMachine: boolean = false,
|
||||
timeMachineMaxSize: number = 0
|
||||
): Promise<APIResponse> {
|
||||
@@ -48,12 +56,10 @@ export async function updateSambaShare(
|
||||
id,
|
||||
name,
|
||||
dataset,
|
||||
readOnlyGroups,
|
||||
writeableGroups,
|
||||
permissions,
|
||||
guest,
|
||||
createMask,
|
||||
directoryMask,
|
||||
guestOk,
|
||||
readOnly,
|
||||
timeMachine,
|
||||
timeMachineMaxSize
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import CustomValueInput from '$lib/components/ui/custom-input/value.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import type { Group } from '$lib/types/auth';
|
||||
import type { Group, User } from '$lib/types/auth';
|
||||
import type { APIResponse } from '$lib/types/common';
|
||||
import type { SambaShare } from '$lib/types/samba/shares';
|
||||
import type { Dataset } from '$lib/types/zfs/dataset';
|
||||
@@ -17,6 +17,7 @@
|
||||
shares: SambaShare[];
|
||||
datasets: Dataset[];
|
||||
groups: Group[];
|
||||
users: User[];
|
||||
share?: SambaShare | null;
|
||||
edit?: boolean;
|
||||
reload?: boolean;
|
||||
@@ -28,12 +29,23 @@
|
||||
shares,
|
||||
datasets,
|
||||
groups,
|
||||
users,
|
||||
share,
|
||||
edit = false,
|
||||
reload = $bindable(),
|
||||
appleExtensions = false
|
||||
}: Props = $props();
|
||||
|
||||
const userOptions = users.map((user) => ({
|
||||
label: user.username,
|
||||
value: String(user.id)
|
||||
}));
|
||||
|
||||
const groupOptions = groups.map((group) => ({
|
||||
label: group.name,
|
||||
value: String(group.id)
|
||||
}));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let options = {
|
||||
name: share ? share.name : '',
|
||||
@@ -55,36 +67,68 @@
|
||||
}))
|
||||
}
|
||||
},
|
||||
readOnlyGroups: {
|
||||
readUsers: {
|
||||
combobox: {
|
||||
open: false,
|
||||
value: share ? share.readOnlyGroups.map((group) => group.name) : ([] as string[]),
|
||||
options: groups.map((group) => ({
|
||||
label: group.name,
|
||||
value: group.name
|
||||
}))
|
||||
value: share ? share.permissions.read.users.map((user) => String(user.id)) : ([] as string[]),
|
||||
options: userOptions
|
||||
}
|
||||
},
|
||||
writeableGroups: {
|
||||
writeUsers: {
|
||||
combobox: {
|
||||
open: false,
|
||||
value: share ? share.writeableGroups.map((group) => group.name) : ([] as string[]),
|
||||
options: groups.map((group) => ({
|
||||
label: group.name,
|
||||
value: group.name
|
||||
}))
|
||||
value: share
|
||||
? share.permissions.write.users.map((user) => String(user.id))
|
||||
: ([] as string[]),
|
||||
options: userOptions
|
||||
}
|
||||
},
|
||||
readGroups: {
|
||||
combobox: {
|
||||
open: false,
|
||||
value: share ? share.permissions.read.groups.map((group) => String(group.id)) : ([] as string[]),
|
||||
options: groupOptions
|
||||
}
|
||||
},
|
||||
writeGroups: {
|
||||
combobox: {
|
||||
open: false,
|
||||
value: share
|
||||
? share.permissions.write.groups.map((group) => String(group.id))
|
||||
: ([] as string[]),
|
||||
options: groupOptions
|
||||
}
|
||||
},
|
||||
createMask: share ? share.createMask : '0664',
|
||||
directoryMask: share ? share.directoryMask : '2775',
|
||||
guestOk: share ? share.guestOk : false,
|
||||
readOnly: share ? share.readOnly : false,
|
||||
guest: {
|
||||
enabled: share ? share.guest.enabled : false,
|
||||
writeable: share ? share.guest.writeable : false
|
||||
},
|
||||
timeMachine: share ? share.timeMachine : false,
|
||||
timeMachineMaxSize: share ? share.timeMachineMaxSize : 0
|
||||
};
|
||||
|
||||
let properties = $state(options);
|
||||
|
||||
function normalizeWriteWins() {
|
||||
const writeUsers = new Set(properties.writeUsers.combobox.value);
|
||||
properties.readUsers.combobox.value = properties.readUsers.combobox.value.filter(
|
||||
(id) => !writeUsers.has(id)
|
||||
);
|
||||
|
||||
const writeGroups = new Set(properties.writeGroups.combobox.value);
|
||||
properties.readGroups.combobox.value = properties.readGroups.combobox.value.filter(
|
||||
(id) => !writeGroups.has(id)
|
||||
);
|
||||
}
|
||||
|
||||
function toIDList(values: string[]): number[] {
|
||||
return values
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
}
|
||||
|
||||
async function createOrEdit() {
|
||||
let error = '';
|
||||
|
||||
@@ -96,20 +140,20 @@
|
||||
error = 'Name is required';
|
||||
} else if (properties.dataset.combobox.value === '') {
|
||||
error = 'Dataset is required';
|
||||
} else if (
|
||||
properties.readOnlyGroups.combobox.value.length === 0 &&
|
||||
properties.writeableGroups.combobox.value.length === 0 &&
|
||||
!properties.guestOk
|
||||
) {
|
||||
error = 'No groups selected and guests are not allowed';
|
||||
}
|
||||
|
||||
if (
|
||||
properties.readOnlyGroups.combobox.value.some((group) =>
|
||||
properties.writeableGroups.combobox.value.includes(group)
|
||||
)
|
||||
) {
|
||||
error = 'Share cannot have overlapping groups';
|
||||
const totalPrincipals =
|
||||
properties.readUsers.combobox.value.length +
|
||||
properties.writeUsers.combobox.value.length +
|
||||
properties.readGroups.combobox.value.length +
|
||||
properties.writeGroups.combobox.value.length;
|
||||
|
||||
if (!properties.guest.enabled && totalPrincipals === 0) {
|
||||
error = 'Select at least one user or group for authenticated access';
|
||||
}
|
||||
|
||||
if (properties.guest.enabled && totalPrincipals > 0) {
|
||||
error = 'Guest-only shares cannot include users or groups';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -119,6 +163,22 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = {
|
||||
read: {
|
||||
userIds: toIDList(properties.readUsers.combobox.value),
|
||||
groupIds: toIDList(properties.readGroups.combobox.value)
|
||||
},
|
||||
write: {
|
||||
userIds: toIDList(properties.writeUsers.combobox.value),
|
||||
groupIds: toIDList(properties.writeGroups.combobox.value)
|
||||
}
|
||||
};
|
||||
|
||||
const guest = {
|
||||
enabled: properties.guest.enabled,
|
||||
writeable: properties.guest.writeable
|
||||
};
|
||||
|
||||
let response: APIResponse;
|
||||
|
||||
if (edit) {
|
||||
@@ -126,12 +186,10 @@
|
||||
share!.id,
|
||||
properties.name,
|
||||
properties.dataset.combobox.value,
|
||||
properties.readOnlyGroups.combobox.value,
|
||||
properties.writeableGroups.combobox.value,
|
||||
permissions,
|
||||
guest,
|
||||
properties.createMask,
|
||||
properties.directoryMask,
|
||||
properties.guestOk,
|
||||
properties.readOnly,
|
||||
properties.timeMachine,
|
||||
Number(properties.timeMachineMaxSize)
|
||||
);
|
||||
@@ -139,11 +197,10 @@
|
||||
response = await createSambaShare(
|
||||
properties.name,
|
||||
properties.dataset.combobox.value,
|
||||
properties.readOnlyGroups.combobox.value,
|
||||
properties.writeableGroups.combobox.value,
|
||||
permissions,
|
||||
guest,
|
||||
properties.createMask,
|
||||
properties.directoryMask,
|
||||
properties.guestOk,
|
||||
properties.timeMachine,
|
||||
Number(properties.timeMachineMaxSize)
|
||||
);
|
||||
@@ -167,11 +224,22 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (properties.readOnly) {
|
||||
if (properties.readOnlyGroups.combobox.value.length > 0) {
|
||||
properties.readOnlyGroups.combobox.value = [];
|
||||
if (properties.guest.enabled) {
|
||||
if (properties.readUsers.combobox.value.length > 0) {
|
||||
properties.readUsers.combobox.value = [];
|
||||
}
|
||||
if (properties.writeUsers.combobox.value.length > 0) {
|
||||
properties.writeUsers.combobox.value = [];
|
||||
}
|
||||
if (properties.readGroups.combobox.value.length > 0) {
|
||||
properties.readGroups.combobox.value = [];
|
||||
}
|
||||
if (properties.writeGroups.combobox.value.length > 0) {
|
||||
properties.writeGroups.combobox.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
normalizeWriteWins();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -239,35 +307,69 @@
|
||||
bind:open={properties.dataset.combobox.open}
|
||||
bind:value={properties.dataset.combobox.value}
|
||||
data={properties.dataset.combobox.options}
|
||||
multiple={false}
|
||||
width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CustomComboBox
|
||||
label="Read-Only Groups"
|
||||
placeholder="Select groups"
|
||||
bind:open={properties.readOnlyGroups.combobox.open}
|
||||
bind:value={properties.readOnlyGroups.combobox.value}
|
||||
data={properties.readOnlyGroups.combobox.options}
|
||||
disabled={properties.readOnly}
|
||||
multiple={true}
|
||||
width="w-full"
|
||||
width="w-2/5"
|
||||
/>
|
||||
|
||||
<CustomComboBox
|
||||
label="Writeable Groups"
|
||||
placeholder="Select groups"
|
||||
bind:open={properties.writeableGroups.combobox.open}
|
||||
bind:value={properties.writeableGroups.combobox.value}
|
||||
data={properties.writeableGroups.combobox.options}
|
||||
multiple={true}
|
||||
width="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
|
||||
<Label for="guest-mode">Guest Only</Label>
|
||||
<Checkbox id="guest-mode" bind:checked={properties.guest.enabled} />
|
||||
</div>
|
||||
|
||||
{#if !properties.guest.enabled}
|
||||
<CustomComboBox
|
||||
label="Read Users"
|
||||
placeholder="Select users"
|
||||
bind:open={properties.readUsers.combobox.open}
|
||||
bind:value={properties.readUsers.combobox.value}
|
||||
data={properties.readUsers.combobox.options}
|
||||
multiple={true}
|
||||
showCount={true}
|
||||
showCountLabel=" users"
|
||||
width="w-2/5"
|
||||
/>
|
||||
|
||||
<CustomComboBox
|
||||
label="Write Users"
|
||||
placeholder="Select users"
|
||||
bind:open={properties.writeUsers.combobox.open}
|
||||
bind:value={properties.writeUsers.combobox.value}
|
||||
data={properties.writeUsers.combobox.options}
|
||||
multiple={true}
|
||||
showCount={true}
|
||||
showCountLabel=" users"
|
||||
width="w-2/5"
|
||||
/>
|
||||
|
||||
<CustomComboBox
|
||||
label="Read Groups"
|
||||
placeholder="Select groups"
|
||||
bind:open={properties.readGroups.combobox.open}
|
||||
bind:value={properties.readGroups.combobox.value}
|
||||
data={properties.readGroups.combobox.options}
|
||||
multiple={true}
|
||||
showCount={true}
|
||||
showCountLabel=" groups"
|
||||
width="w-2/5"
|
||||
/>
|
||||
|
||||
<CustomComboBox
|
||||
label="Write Groups"
|
||||
placeholder="Select groups"
|
||||
bind:open={properties.writeGroups.combobox.open}
|
||||
bind:value={properties.writeGroups.combobox.value}
|
||||
data={properties.writeGroups.combobox.options}
|
||||
multiple={true}
|
||||
showCount={true}
|
||||
showCountLabel=" groups"
|
||||
width="w-2/5"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
|
||||
<Label for="guest-writeable">Guest Writeable</Label>
|
||||
<Checkbox id="guest-writeable" bind:checked={properties.guest.writeable} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CustomValueInput
|
||||
label="Create Mask"
|
||||
placeholder="0664"
|
||||
@@ -281,55 +383,27 @@
|
||||
bind:value={properties.directoryMask}
|
||||
classes="flex-1 space-y-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="guests" bind:checked={properties.guestOk} />
|
||||
<Label for="guests" class="text-sm font-medium">Guests</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="read-only" bind:checked={properties.readOnly} />
|
||||
<Label for="read-only" class="text-sm font-medium">Read Only</Label>
|
||||
</div>
|
||||
|
||||
{#if appleExtensions}
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center justify-between gap-2 rounded border p-2 md:col-span-2">
|
||||
<Label for="time-machine">Time Machine</Label>
|
||||
<Checkbox id="time-machine" bind:checked={properties.timeMachine} />
|
||||
<Label for="time-machine" class="text-sm font-medium">Time Machine</Label>
|
||||
</div>
|
||||
|
||||
{#if properties.timeMachine}
|
||||
<CustomValueInput
|
||||
label="Time Machine Max Size (GB)"
|
||||
placeholder="0"
|
||||
bind:value={properties.timeMachineMaxSize}
|
||||
classes="flex-1 space-y-1.5 md:col-span-2"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if appleExtensions && properties.timeMachine}
|
||||
<CustomValueInput
|
||||
label="Time Machine Max Size"
|
||||
hint="Time Machine Max Size (GB, 0 = unlimited)"
|
||||
placeholder="0"
|
||||
bind:value={properties.timeMachineMaxSize}
|
||||
classes="flex-1 space-y-1.5"
|
||||
type="number"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<div class="flex items-center justify-end space-x-4">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
class="h-8 w-full lg:w-28"
|
||||
onclick={() => {
|
||||
createOrEdit();
|
||||
}}
|
||||
>
|
||||
{#if edit}
|
||||
Edit
|
||||
{:else}
|
||||
Create
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||
<Button onclick={createOrEdit}>{edit ? 'Save' : 'Create'}</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
import { z } from 'zod/v4';
|
||||
import { GroupSchema } from '../auth';
|
||||
import { GroupSchema, UserSchema } from '../auth';
|
||||
|
||||
export const SambaPrincipalUserSchema = UserSchema.pick({
|
||||
id: true,
|
||||
username: true
|
||||
});
|
||||
|
||||
export const SambaPrincipalGroupSchema = GroupSchema.pick({
|
||||
id: true,
|
||||
name: true
|
||||
});
|
||||
|
||||
export const SambaPrincipalSetSchema = z.object({
|
||||
users: z.array(SambaPrincipalUserSchema).default([]),
|
||||
groups: z.array(SambaPrincipalGroupSchema).default([])
|
||||
});
|
||||
|
||||
export const SambaPermissionsSchema = z.object({
|
||||
read: SambaPrincipalSetSchema,
|
||||
write: SambaPrincipalSetSchema
|
||||
});
|
||||
|
||||
export const SambaGuestSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
writeable: z.boolean()
|
||||
});
|
||||
|
||||
export const SambaShareSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
dataset: z.string(),
|
||||
readOnlyGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
|
||||
writeableGroups: z.preprocess((val) => (val == null ? [] : val), z.array(GroupSchema)),
|
||||
permissions: SambaPermissionsSchema,
|
||||
guest: SambaGuestSchema,
|
||||
createMask: z.string(),
|
||||
directoryMask: z.string(),
|
||||
guestOk: z.boolean(),
|
||||
readOnly: z.boolean(),
|
||||
timeMachine: z.boolean().default(false),
|
||||
timeMachineMaxSize: z.number().default(0),
|
||||
createdAt: z.string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { listGroups } from '$lib/api/auth/groups';
|
||||
import { listUsers } from '$lib/api/auth/local';
|
||||
import { getSambaConfig } from '$lib/api/samba/config';
|
||||
import { deleteSambaShare, getSambaShares } from '$lib/api/samba/share';
|
||||
import { getDatasets } from '$lib/api/zfs/datasets';
|
||||
@@ -8,7 +9,7 @@
|
||||
import TreeTable from '$lib/components/custom/TreeTable.svelte';
|
||||
import Search from '$lib/components/custom/TreeTable/Search.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import type { Group } from '$lib/types/auth';
|
||||
import type { Group, User } from '$lib/types/auth';
|
||||
import type { Column, Row } from '$lib/types/components/tree-table';
|
||||
import type { SambaConfig } from '$lib/types/samba/config';
|
||||
import type { SambaShare } from '$lib/types/samba/shares';
|
||||
@@ -23,6 +24,7 @@
|
||||
shares: SambaShare[];
|
||||
datasets: Dataset[];
|
||||
groups: Group[];
|
||||
users: User[];
|
||||
sambaConfig: SambaConfig;
|
||||
}
|
||||
|
||||
@@ -67,6 +69,19 @@
|
||||
}
|
||||
);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let users = resource(
|
||||
() => 'users',
|
||||
async () => {
|
||||
const result = await listUsers();
|
||||
updateCache('users', result);
|
||||
return result;
|
||||
},
|
||||
{
|
||||
initialValue: data.users
|
||||
}
|
||||
);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let sambaConfig = resource(
|
||||
() => 'samba-config',
|
||||
@@ -89,6 +104,7 @@
|
||||
datasets.refetch();
|
||||
shares.refetch();
|
||||
groups.refetch();
|
||||
users.refetch();
|
||||
reload = false;
|
||||
}
|
||||
}
|
||||
@@ -113,22 +129,16 @@
|
||||
let properties = $state(options);
|
||||
let query = $state('');
|
||||
|
||||
function generateTableData(
|
||||
shares: SambaShare[],
|
||||
datasets: Dataset[]
|
||||
): {
|
||||
function generateTableData(shares: SambaShare[], datasets: Dataset[]): {
|
||||
rows: Row[];
|
||||
columns: Column[];
|
||||
} {
|
||||
function groupFormatter(cell: CellComponent) {
|
||||
const groups = cell.getValue() as Group[];
|
||||
if (!groups?.length) return '-';
|
||||
function principalFormatter(cell: CellComponent) {
|
||||
const principals = cell.getValue() as string[];
|
||||
if (!principals?.length) return '-';
|
||||
|
||||
const shown = groups
|
||||
.slice(0, 5)
|
||||
.map((g) => g.name)
|
||||
.join(', ');
|
||||
return groups.length > 5 ? `${shown}, …` : shown;
|
||||
const shown = principals.slice(0, 5).join(', ');
|
||||
return principals.length > 5 ? `${shown}, …` : shown;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
@@ -147,14 +157,14 @@
|
||||
title: 'Mount Point'
|
||||
},
|
||||
{
|
||||
field: 'readOnlyGroups',
|
||||
title: 'Read-Only Groups',
|
||||
formatter: groupFormatter
|
||||
field: 'readAccess',
|
||||
title: 'Read Access',
|
||||
formatter: principalFormatter
|
||||
},
|
||||
{
|
||||
field: 'writeableGroups',
|
||||
title: 'Writeable Groups',
|
||||
formatter: groupFormatter
|
||||
field: 'writeAccess',
|
||||
title: 'Write Access',
|
||||
formatter: principalFormatter
|
||||
},
|
||||
{
|
||||
field: 'created',
|
||||
@@ -168,12 +178,21 @@
|
||||
|
||||
for (const share of shares) {
|
||||
const dataset = datasets.find((ds) => ds.guid === share.dataset);
|
||||
const readAccess = [
|
||||
...share.permissions.read.users.map((user) => user.username),
|
||||
...share.permissions.read.groups.map((group) => `@${group.name}`)
|
||||
];
|
||||
const writeAccess = [
|
||||
...share.permissions.write.users.map((user) => user.username),
|
||||
...share.permissions.write.groups.map((group) => `@${group.name}`)
|
||||
];
|
||||
|
||||
const row: Row = {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
mountpoint: dataset ? dataset.mountpoint : '-',
|
||||
readOnlyGroups: share.readOnlyGroups || [],
|
||||
writeableGroups: share.writeableGroups || [],
|
||||
readAccess,
|
||||
writeAccess,
|
||||
created: share.createdAt
|
||||
};
|
||||
|
||||
@@ -266,6 +285,7 @@
|
||||
shares={shares.current}
|
||||
datasets={datasets.current}
|
||||
groups={groups.current}
|
||||
users={users.current}
|
||||
appleExtensions={sambaConfig.current.appleExtensions}
|
||||
bind:reload
|
||||
/>
|
||||
@@ -277,6 +297,7 @@
|
||||
shares={shares.current}
|
||||
datasets={datasets.current}
|
||||
groups={groups.current}
|
||||
users={users.current}
|
||||
share={properties.edit.share}
|
||||
edit={properties.edit.open}
|
||||
appleExtensions={sambaConfig.current.appleExtensions}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { listGroups } from '$lib/api/auth/groups';
|
||||
import { listUsers } from '$lib/api/auth/local';
|
||||
import { getSambaConfig } from '$lib/api/samba/config';
|
||||
import { getSambaShares } from '$lib/api/samba/share';
|
||||
import { getDatasets } from '$lib/api/zfs/datasets';
|
||||
@@ -8,7 +9,7 @@ import { cachedFetch } from '$lib/utils/http';
|
||||
|
||||
export async function load() {
|
||||
const cacheDuration = SEVEN_DAYS;
|
||||
const [datasets, shares, groups, sambaConfig] = await Promise.all([
|
||||
const [datasets, shares, groups, users, sambaConfig] = await Promise.all([
|
||||
cachedFetch(
|
||||
'zfs-filesystems',
|
||||
async () => await getDatasets(GZFSDatasetTypeSchema.enum.FILESYSTEM),
|
||||
@@ -16,6 +17,7 @@ export async function load() {
|
||||
),
|
||||
cachedFetch('samba-shares', async () => await getSambaShares(), cacheDuration),
|
||||
cachedFetch('groups', async () => await listGroups(), cacheDuration),
|
||||
cachedFetch('users', async () => await listUsers(), cacheDuration),
|
||||
cachedFetch('samba-config', async () => await getSambaConfig(), cacheDuration)
|
||||
]);
|
||||
|
||||
@@ -23,6 +25,7 @@ export async function load() {
|
||||
datasets,
|
||||
shares,
|
||||
groups,
|
||||
users,
|
||||
sambaConfig
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user