diff --git a/docs/app-docs/src/content/docs/guides/node/storage/samba/shares/index.mdx b/docs/app-docs/src/content/docs/guides/node/storage/samba/shares/index.mdx index e8d0b25c..2bcaba2c 100644 --- a/docs/app-docs/src/content/docs/guides/node/storage/samba/shares/index.mdx +++ b/docs/app-docs/src/content/docs/guides/node/storage/samba/shares/index.mdx @@ -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. +::: + ![Opening the Samba Share](./conn-1.png) In the "Connect to Server" dialog, enter the address of your Sylve server in the following format: @@ -66,4 +76,4 @@ smb:/// After the authentication is successful, you should see the contents of the share in the Finder. -![Contents of the Samba Share](./conn-3.png) \ No newline at end of file +![Contents of the Samba Share](./conn-3.png) diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 21a0262a..631fc536 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -2350,6 +2350,172 @@ const docTemplate = `{ } } }, + "/jail/bootstrap": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Start a pkgbase bootstrap for the given pool, version, and type. Returns immediately; bootstrap runs asynchronously.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Create bootstrap", + "parameters": [ + { + "description": "Bootstrap Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest" + } + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Destroy a completed pkgbase bootstrap dataset and remove its DB record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Delete bootstrap", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Bootstrap name (e.g. 15-0-Base)", + "name": "name", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, + "/jail/bootstraps": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List all supported pkgbase bootstrap entries for a pool, with their current install status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "List bootstraps", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, "/jail/cpu": { "put": { "security": [ @@ -3873,6 +4039,57 @@ const docTemplate = `{ } } }, + "/network/object/bulk-delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple network objects by their IDs; fails if any object is in use", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Network" + ], + "summary": "Bulk Delete Network Objects", + "parameters": [ + { + "description": "Bulk Delete Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_network.BulkDeleteNetworkObjectsRequest" + } + } + ], + "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" + } + } + } + } + }, "/network/object/{id}": { "put": { "security": [ @@ -4371,6 +4588,57 @@ const docTemplate = `{ } } }, + "/options/boot-rom/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify the Boot ROM mode of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Boot ROM of a Virtual Machine", + "parameters": [ + { + "description": "Modify Boot ROM Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyBootROMRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/clock/:rid": { "put": { "security": [ @@ -4524,6 +4792,57 @@ const docTemplate = `{ } } }, + "/options/extra-bhyve-options/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify custom bhyve arguments (one argument per line) of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Extra Bhyve Options of a Virtual Machine", + "parameters": [ + { + "description": "Modify Extra Bhyve Options Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyExtraBhyveOptionsRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/fstab/:rid": { "put": { "security": [ @@ -5226,7 +5545,7 @@ const docTemplate = `{ "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": { @@ -8981,26 +9300,6 @@ const docTemplate = `{ } } }, - "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare" - } - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_utilities_CloudInitTemplate": { "type": "object", "properties": { @@ -9141,6 +9440,26 @@ const docTemplate = `{ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_SimpleList": { "type": "object", "properties": { @@ -9261,6 +9580,26 @@ const docTemplate = `{ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaShareResponse" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-github_com_alchemillahq_sylve_internal_db_models_BasicSettings": { "type": "object", "properties": { @@ -9670,14 +10009,20 @@ const docTemplate = `{ "jails", "samba-server", "virtualization", - "wol-server" + "wol-server", + "firewall", + "wireguard", + "iscsi" ], "x-enum-varnames": [ "DHCPServer", "Jails", "SambaServer", "Virtualization", - "WoLServer" + "WoLServer", + "Firewall", + "WireGuard", + "ISCSI" ] }, "github_com_alchemillahq_sylve_internal_db_models.BasicSettings": { @@ -9787,24 +10132,51 @@ const docTemplate = `{ "createdAt": { "type": "string" }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, "groups": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" } }, + "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": { "type": "array", "items": { @@ -9814,6 +10186,9 @@ const docTemplate = `{ "totp": { "type": "string" }, + "uid": { + "type": "integer" + }, "updatedAt": { "type": "string" }, @@ -10468,6 +10843,9 @@ const docTemplate = `{ "github_com_alchemillahq_sylve_internal_db_models_network.Object": { "type": "object", "properties": { + "autoUpdate": { + "type": "boolean" + }, "createdAt": { "type": "string" }, @@ -10490,17 +10868,32 @@ const docTemplate = `{ "description": "\"\", \"dhcp\" for now", "type": "string" }, + "lastRefreshAt": { + "type": "string" + }, + "lastRefreshError": { + "type": "string" + }, "name": { "type": "string" }, + "refreshIntervalSeconds": { + "type": "integer" + }, + "resolutionChecksum": { + "type": "string" + }, "resolutions": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_network.ObjectResolution" } }, + "sourceChecksum": { + "type": "string" + }, "type": { - "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\"", + "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\", \"FQDN\", \"DUID\"", "type": "string" }, "updatedAt": { @@ -10542,7 +10935,10 @@ const docTemplate = `{ "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": { @@ -10677,6 +11073,9 @@ const docTemplate = `{ "github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -10697,53 +11096,6 @@ const docTemplate = `{ } } }, - "github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare": { - "type": "object", - "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": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - }, - "updatedAt": { - "type": "string" - }, - "writeableGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - } - } - }, "github_com_alchemillahq_sylve_internal_db_models_utilities.CloudInitTemplate": { "type": "object", "properties": { @@ -11001,6 +11353,9 @@ const docTemplate = `{ "apic": { "type": "boolean" }, + "bootRom": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM" + }, "cloudInitData": { "type": "string" }, @@ -11031,6 +11386,12 @@ const docTemplate = `{ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "integer" }, @@ -11103,6 +11464,9 @@ const docTemplate = `{ "updatedAt": { "type": "string" }, + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -11123,6 +11487,17 @@ const docTemplate = `{ } } }, + "github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM": { + "type": "string", + "enum": [ + "uefi", + "none" + ], + "x-enum-varnames": [ + "VMBootROMUEFI", + "VMBootROMNone" + ] + }, "github_com_alchemillahq_sylve_internal_db_models_vm.VMCPUPinning": { "type": "object", "properties": { @@ -11690,6 +12065,69 @@ const docTemplate = `{ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry": { + "type": "object", + "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" + } + } + }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest": { + "type": "object", + "required": [ + "major", + "pool", + "type" + ], + "properties": { + "major": { + "type": "integer" + }, + "minor": { + "type": "integer" + }, + "pool": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_jail.CreateJailRequest": { "type": "object", "required": [ @@ -11711,6 +12149,9 @@ const docTemplate = `{ "base": { "type": "string" }, + "bootstrapName": { + "type": "string" + }, "cleanEnvironment": { "type": "boolean" }, @@ -11961,6 +12402,9 @@ const docTemplate = `{ "apic": { "type": "boolean" }, + "bootRom": { + "type": "string" + }, "cloudInit": { "type": "boolean" }, @@ -11991,6 +12435,12 @@ const docTemplate = `{ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "ignoreUMSR": { "type": "boolean" }, @@ -12051,6 +12501,12 @@ const docTemplate = `{ "tpmEmulation": { "type": "boolean" }, + "vncBind": { + "type": "string" + }, + "vncEnabled": { + "type": "boolean" + }, "vncPassword": { "type": "string" }, @@ -12115,6 +12571,9 @@ const docTemplate = `{ "vncResolution" ], "properties": { + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -12695,6 +13154,9 @@ const docTemplate = `{ "datasetType": { "$ref": "#/definitions/gzfs.DatasetType" }, + "nameFilter": { + "type": "string" + }, "page": { "type": "integer" }, @@ -13545,20 +14007,56 @@ const docTemplate = `{ "type": "object", "required": [ "admin", - "password", "username" ], "properties": { "admin": { "type": "boolean" }, + "auxGroupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, + "homeDirPerms": { + "type": "integer" + }, + "homeDirectory": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "newPrimaryGroup": { + "type": "boolean" + }, "password": { - "type": "string", - "maxLength": 128, - "minLength": 3 + "type": "string" + }, + "primaryGroupId": { + "type": "integer" + }, + "shell": { + "type": "string" + }, + "sshPublicKey": { + "type": "string" + }, + "uid": { + "type": "integer" }, "username": { "type": "string", @@ -13908,6 +14406,20 @@ const docTemplate = `{ } } }, + "internal_handlers_network.BulkDeleteNetworkObjectsRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "internal_handlers_network.CreateManualSwitchRequest": { "type": "object", "required": [ @@ -14098,32 +14610,29 @@ const docTemplate = `{ "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": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, "internal_handlers_samba.SambaConfigRequest": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -14141,6 +14650,144 @@ const docTemplate = `{ } } }, + "internal_handlers_samba.SambaGuestRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaGuestResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaPermissionsRequest": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + } + } + }, + "internal_handlers_samba.SambaPermissionsResponse": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + } + } + }, + "internal_handlers_samba.SambaPrincipalGroupResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaPrincipalIDsRequest": { + "type": "object", + "properties": { + "groupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "userIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalSetResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalGroupResponse" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalUserResponse" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalUserResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaShareResponse": { + "type": "object", + "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" + } + } + }, "internal_handlers_samba.UpdateSambaShareRequest": { "type": "object", "properties": { @@ -14153,8 +14800,8 @@ const docTemplate = `{ "directoryMask": { "type": "string" }, - "guestOk": { - "type": "boolean" + "guest": { + "$ref": "#/definitions/internal_handlers_samba.SambaGuestRequest" }, "id": { "type": "integer" @@ -14162,20 +14809,14 @@ const docTemplate = `{ "name": { "type": "string" }, - "readOnly": { + "permissions": { + "$ref": "#/definitions/internal_handlers_samba.SambaPermissionsRequest" + }, + "timeMachine": { "type": "boolean" }, - "readOnlyGroups": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, @@ -14267,6 +14908,14 @@ const docTemplate = `{ } } }, + "internal_handlers_vm.ModifyBootROMRequest": { + "type": "object", + "properties": { + "bootRom": { + "type": "string" + } + } + }, "internal_handlers_vm.ModifyClockRequest": { "type": "object", "properties": { @@ -14289,6 +14938,17 @@ const docTemplate = `{ } } }, + "internal_handlers_vm.ModifyExtraBhyveOptionsRequest": { + "type": "object", + "properties": { + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "internal_handlers_vm.ModifyIgnoreUMSRsRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 99c4c46d..bfd31a38 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -2344,6 +2344,172 @@ } } }, + "/jail/bootstrap": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Start a pkgbase bootstrap for the given pool, version, and type. Returns immediately; bootstrap runs asynchronously.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Create bootstrap", + "parameters": [ + { + "description": "Bootstrap Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest" + } + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Destroy a completed pkgbase bootstrap dataset and remove its DB record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Delete bootstrap", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Bootstrap name (e.g. 15-0-Base)", + "name": "name", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, + "/jail/bootstraps": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List all supported pkgbase bootstrap entries for a pool, with their current install status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "List bootstraps", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, "/jail/cpu": { "put": { "security": [ @@ -3867,6 +4033,57 @@ } } }, + "/network/object/bulk-delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple network objects by their IDs; fails if any object is in use", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Network" + ], + "summary": "Bulk Delete Network Objects", + "parameters": [ + { + "description": "Bulk Delete Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_network.BulkDeleteNetworkObjectsRequest" + } + } + ], + "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" + } + } + } + } + }, "/network/object/{id}": { "put": { "security": [ @@ -4365,6 +4582,57 @@ } } }, + "/options/boot-rom/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify the Boot ROM mode of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Boot ROM of a Virtual Machine", + "parameters": [ + { + "description": "Modify Boot ROM Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyBootROMRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/clock/:rid": { "put": { "security": [ @@ -4518,6 +4786,57 @@ } } }, + "/options/extra-bhyve-options/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify custom bhyve arguments (one argument per line) of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Extra Bhyve Options of a Virtual Machine", + "parameters": [ + { + "description": "Modify Extra Bhyve Options Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyExtraBhyveOptionsRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/fstab/:rid": { "put": { "security": [ @@ -5220,7 +5539,7 @@ "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": { @@ -8975,26 +9294,6 @@ } } }, - "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare" - } - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_utilities_CloudInitTemplate": { "type": "object", "properties": { @@ -9135,6 +9434,26 @@ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_SimpleList": { "type": "object", "properties": { @@ -9255,6 +9574,26 @@ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaShareResponse" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-github_com_alchemillahq_sylve_internal_db_models_BasicSettings": { "type": "object", "properties": { @@ -9664,14 +10003,20 @@ "jails", "samba-server", "virtualization", - "wol-server" + "wol-server", + "firewall", + "wireguard", + "iscsi" ], "x-enum-varnames": [ "DHCPServer", "Jails", "SambaServer", "Virtualization", - "WoLServer" + "WoLServer", + "Firewall", + "WireGuard", + "ISCSI" ] }, "github_com_alchemillahq_sylve_internal_db_models.BasicSettings": { @@ -9781,24 +10126,51 @@ "createdAt": { "type": "string" }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, "groups": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" } }, + "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": { "type": "array", "items": { @@ -9808,6 +10180,9 @@ "totp": { "type": "string" }, + "uid": { + "type": "integer" + }, "updatedAt": { "type": "string" }, @@ -10462,6 +10837,9 @@ "github_com_alchemillahq_sylve_internal_db_models_network.Object": { "type": "object", "properties": { + "autoUpdate": { + "type": "boolean" + }, "createdAt": { "type": "string" }, @@ -10484,17 +10862,32 @@ "description": "\"\", \"dhcp\" for now", "type": "string" }, + "lastRefreshAt": { + "type": "string" + }, + "lastRefreshError": { + "type": "string" + }, "name": { "type": "string" }, + "refreshIntervalSeconds": { + "type": "integer" + }, + "resolutionChecksum": { + "type": "string" + }, "resolutions": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_network.ObjectResolution" } }, + "sourceChecksum": { + "type": "string" + }, "type": { - "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\"", + "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\", \"FQDN\", \"DUID\"", "type": "string" }, "updatedAt": { @@ -10536,7 +10929,10 @@ "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": { @@ -10671,6 +11067,9 @@ "github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -10691,53 +11090,6 @@ } } }, - "github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare": { - "type": "object", - "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": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - }, - "updatedAt": { - "type": "string" - }, - "writeableGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - } - } - }, "github_com_alchemillahq_sylve_internal_db_models_utilities.CloudInitTemplate": { "type": "object", "properties": { @@ -10995,6 +11347,9 @@ "apic": { "type": "boolean" }, + "bootRom": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM" + }, "cloudInitData": { "type": "string" }, @@ -11025,6 +11380,12 @@ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "integer" }, @@ -11097,6 +11458,9 @@ "updatedAt": { "type": "string" }, + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -11117,6 +11481,17 @@ } } }, + "github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM": { + "type": "string", + "enum": [ + "uefi", + "none" + ], + "x-enum-varnames": [ + "VMBootROMUEFI", + "VMBootROMNone" + ] + }, "github_com_alchemillahq_sylve_internal_db_models_vm.VMCPUPinning": { "type": "object", "properties": { @@ -11684,6 +12059,69 @@ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry": { + "type": "object", + "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" + } + } + }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest": { + "type": "object", + "required": [ + "major", + "pool", + "type" + ], + "properties": { + "major": { + "type": "integer" + }, + "minor": { + "type": "integer" + }, + "pool": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_jail.CreateJailRequest": { "type": "object", "required": [ @@ -11705,6 +12143,9 @@ "base": { "type": "string" }, + "bootstrapName": { + "type": "string" + }, "cleanEnvironment": { "type": "boolean" }, @@ -11955,6 +12396,9 @@ "apic": { "type": "boolean" }, + "bootRom": { + "type": "string" + }, "cloudInit": { "type": "boolean" }, @@ -11985,6 +12429,12 @@ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "ignoreUMSR": { "type": "boolean" }, @@ -12045,6 +12495,12 @@ "tpmEmulation": { "type": "boolean" }, + "vncBind": { + "type": "string" + }, + "vncEnabled": { + "type": "boolean" + }, "vncPassword": { "type": "string" }, @@ -12109,6 +12565,9 @@ "vncResolution" ], "properties": { + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -12689,6 +13148,9 @@ "datasetType": { "$ref": "#/definitions/gzfs.DatasetType" }, + "nameFilter": { + "type": "string" + }, "page": { "type": "integer" }, @@ -13539,20 +14001,56 @@ "type": "object", "required": [ "admin", - "password", "username" ], "properties": { "admin": { "type": "boolean" }, + "auxGroupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, + "homeDirPerms": { + "type": "integer" + }, + "homeDirectory": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "newPrimaryGroup": { + "type": "boolean" + }, "password": { - "type": "string", - "maxLength": 128, - "minLength": 3 + "type": "string" + }, + "primaryGroupId": { + "type": "integer" + }, + "shell": { + "type": "string" + }, + "sshPublicKey": { + "type": "string" + }, + "uid": { + "type": "integer" }, "username": { "type": "string", @@ -13902,6 +14400,20 @@ } } }, + "internal_handlers_network.BulkDeleteNetworkObjectsRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "internal_handlers_network.CreateManualSwitchRequest": { "type": "object", "required": [ @@ -14092,32 +14604,29 @@ "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": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, "internal_handlers_samba.SambaConfigRequest": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -14135,6 +14644,144 @@ } } }, + "internal_handlers_samba.SambaGuestRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaGuestResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaPermissionsRequest": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + } + } + }, + "internal_handlers_samba.SambaPermissionsResponse": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + } + } + }, + "internal_handlers_samba.SambaPrincipalGroupResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaPrincipalIDsRequest": { + "type": "object", + "properties": { + "groupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "userIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalSetResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalGroupResponse" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalUserResponse" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalUserResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaShareResponse": { + "type": "object", + "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" + } + } + }, "internal_handlers_samba.UpdateSambaShareRequest": { "type": "object", "properties": { @@ -14147,8 +14794,8 @@ "directoryMask": { "type": "string" }, - "guestOk": { - "type": "boolean" + "guest": { + "$ref": "#/definitions/internal_handlers_samba.SambaGuestRequest" }, "id": { "type": "integer" @@ -14156,20 +14803,14 @@ "name": { "type": "string" }, - "readOnly": { + "permissions": { + "$ref": "#/definitions/internal_handlers_samba.SambaPermissionsRequest" + }, + "timeMachine": { "type": "boolean" }, - "readOnlyGroups": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, @@ -14261,6 +14902,14 @@ } } }, + "internal_handlers_vm.ModifyBootROMRequest": { + "type": "object", + "properties": { + "bootRom": { + "type": "string" + } + } + }, "internal_handlers_vm.ModifyClockRequest": { "type": "object", "properties": { @@ -14283,6 +14932,17 @@ } } }, + "internal_handlers_vm.ModifyExtraBhyveOptionsRequest": { + "type": "object", + "properties": { + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "internal_handlers_vm.ModifyIgnoreUMSRsRequest": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index d5aed425..40df04b0 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -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: diff --git a/internal/assets/swagger/swagger.json b/internal/assets/swagger/swagger.json index 2ca6c3f6..bfd31a38 100644 --- a/internal/assets/swagger/swagger.json +++ b/internal/assets/swagger/swagger.json @@ -13,7 +13,7 @@ "name": "BSD-2-Clause", "url": "https://github.com/AlchemillaHQ/Sylve/blob/master/LICENSE" }, - "version": "0.1.1" + "version": "0.2.3" }, "host": "sylve.lan:8181", "basePath": "/api", @@ -2344,6 +2344,172 @@ } } }, + "/jail/bootstrap": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Start a pkgbase bootstrap for the given pool, version, and type. Returns immediately; bootstrap runs asynchronously.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Create bootstrap", + "parameters": [ + { + "description": "Bootstrap Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest" + } + } + ], + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Destroy a completed pkgbase bootstrap dataset and remove its DB record", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Delete bootstrap", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Bootstrap name (e.g. 15-0-Base)", + "name": "name", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, + "/jail/bootstraps": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List all supported pkgbase bootstrap entries for a pool, with their current install status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "List bootstraps", + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "query", + "required": true + } + ], + "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" + } + } + } + } + }, "/jail/cpu": { "put": { "security": [ @@ -2479,6 +2645,51 @@ } } }, + "/jail/name": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the name of a jail by its ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Jail" + ], + "summary": "Edit a Jail's name", + "parameters": [ + { + "description": "Edit Jail Name Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_jail.JailEditNameRequest" + } + } + ], + "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" + } + } + } + } + }, "/jail/network": { "put": { "security": [ @@ -3305,6 +3516,57 @@ } } }, + "/network/dhcp/lease/dynamic": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete an active DHCP lease by identifier (MAC or DUID) and IP", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Network" + ], + "summary": "Delete Dynamic DHCP Lease", + "parameters": [ + { + "description": "Request Body", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_network.DeleteDynamicLeaseRequest" + } + } + ], + "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" + } + } + } + } + }, "/network/dhcp/lease/{id}": { "delete": { "security": [ @@ -3751,9 +4013,9 @@ ], "responses": { "200": { - "description": "Samba share created successfully", + "description": "ID of the created network object", "schema": { - "type": "string" + "type": "uint" } }, "400": { @@ -3771,6 +4033,57 @@ } } }, + "/network/object/bulk-delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete multiple network objects by their IDs; fails if any object is in use", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Network" + ], + "summary": "Bulk Delete Network Objects", + "parameters": [ + { + "description": "Bulk Delete Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_network.BulkDeleteNetworkObjectsRequest" + } + } + ], + "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" + } + } + } + } + }, "/network/object/{id}": { "put": { "security": [ @@ -4269,6 +4582,57 @@ } } }, + "/options/boot-rom/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify the Boot ROM mode of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Boot ROM of a Virtual Machine", + "parameters": [ + { + "description": "Modify Boot ROM Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyBootROMRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/clock/:rid": { "put": { "security": [ @@ -4422,6 +4786,57 @@ } } }, + "/options/extra-bhyve-options/:rid": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Modify custom bhyve arguments (one argument per line) of a virtual machine", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Modify Extra Bhyve Options of a Virtual Machine", + "parameters": [ + { + "description": "Modify Extra Bhyve Options Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.ModifyExtraBhyveOptionsRequest" + } + } + ], + "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" + } + } + } + } + }, "/options/fstab/:rid": { "put": { "security": [ @@ -5124,7 +5539,7 @@ "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": { @@ -6563,6 +6978,34 @@ } } }, + "/utilities/downloads/paths": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get configured filesystem paths used by downloader for HTTP and Path downloads", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Utilities" + ], + "summary": "Get Download Paths", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_utilities_DownloadPathsResponse" + } + } + } + } + }, "/utilities/downloads/signed-url": { "get": { "security": [ @@ -6998,6 +7441,106 @@ } } }, + "/vm/logs/:rid": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Retrieve console log for a specific VM by RID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Get VM Logs", + "parameters": [ + { + "type": "integer", + "description": "VM RID", + "name": "rid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any" + } + }, + "404": { + "description": "VM Not Found", + "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" + } + } + } + } + }, + "/vm/name": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update the name of a virtual machine by its RID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VM" + ], + "summary": "Edit a Virtual Machine's name", + "parameters": [ + { + "description": "Edit Virtual Machine Name Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers_vm.VMEditNameRequest" + } + } + ], + "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" + } + } + } + } + }, "/vm/simple": { "get": { "security": [ @@ -8751,26 +9294,6 @@ } } }, - "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_samba_SambaShare": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare" - } - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_db_models_utilities_CloudInitTemplate": { "type": "object", "properties": { @@ -8911,6 +9434,26 @@ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_BootstrapEntry": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-array_github_com_alchemillahq_sylve_internal_interfaces_services_jail_SimpleList": { "type": "object", "properties": { @@ -9031,6 +9574,26 @@ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-array_internal_handlers_samba_SambaShareResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaShareResponse" + } + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-github_com_alchemillahq_sylve_internal_db_models_BasicSettings": { "type": "object", "properties": { @@ -9354,6 +9917,23 @@ } } }, + "github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_utilities_DownloadPathsResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/internal_handlers_utilities.DownloadPathsResponse" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_zfs_PoolStatPointResponse": { "type": "object", "properties": { @@ -9423,14 +10003,20 @@ "jails", "samba-server", "virtualization", - "wol-server" + "wol-server", + "firewall", + "wireguard", + "iscsi" ], "x-enum-varnames": [ "DHCPServer", "Jails", "SambaServer", "Virtualization", - "WoLServer" + "WoLServer", + "Firewall", + "WireGuard", + "ISCSI" ] }, "github_com_alchemillahq_sylve_internal_db_models.BasicSettings": { @@ -9540,24 +10126,51 @@ "createdAt": { "type": "string" }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, "groups": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" } }, + "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": { "type": "array", "items": { @@ -9567,6 +10180,9 @@ "totp": { "type": "string" }, + "uid": { + "type": "integer" + }, "updatedAt": { "type": "string" }, @@ -9890,6 +10506,9 @@ }, "updatedAt": { "type": "string" + }, + "wol": { + "type": "boolean" } } }, @@ -10218,6 +10837,9 @@ "github_com_alchemillahq_sylve_internal_db_models_network.Object": { "type": "object", "properties": { + "autoUpdate": { + "type": "boolean" + }, "createdAt": { "type": "string" }, @@ -10240,17 +10862,32 @@ "description": "\"\", \"dhcp\" for now", "type": "string" }, + "lastRefreshAt": { + "type": "string" + }, + "lastRefreshError": { + "type": "string" + }, "name": { "type": "string" }, + "refreshIntervalSeconds": { + "type": "integer" + }, + "resolutionChecksum": { + "type": "string" + }, "resolutions": { "type": "array", "items": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_network.ObjectResolution" } }, + "sourceChecksum": { + "type": "string" + }, "type": { - "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\"", + "description": "\"Host\", \"Mac\", \"Network\", \"Port\", \"Country\", \"List\", \"FQDN\", \"DUID\"", "type": "string" }, "updatedAt": { @@ -10292,7 +10929,10 @@ "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": { @@ -10427,6 +11067,9 @@ "github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -10447,53 +11090,6 @@ } } }, - "github_com_alchemillahq_sylve_internal_db_models_samba.SambaShare": { - "type": "object", - "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": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - }, - "updatedAt": { - "type": "string" - }, - "writeableGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models.Group" - } - } - } - }, "github_com_alchemillahq_sylve_internal_db_models_utilities.CloudInitTemplate": { "type": "object", "properties": { @@ -10693,6 +11289,12 @@ "emulation": { "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMStorageEmulationType" }, + "enable": { + "type": "boolean" + }, + "filesystemTarget": { + "type": "string" + }, "id": { "type": "integer" }, @@ -10702,6 +11304,9 @@ "pool": { "type": "string" }, + "readOnly": { + "type": "boolean" + }, "recordSize": { "type": "integer" }, @@ -10742,6 +11347,9 @@ "apic": { "type": "boolean" }, + "bootRom": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM" + }, "cloudInitData": { "type": "string" }, @@ -10772,6 +11380,12 @@ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "integer" }, @@ -10844,6 +11458,9 @@ "updatedAt": { "type": "string" }, + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -10864,6 +11481,17 @@ } } }, + "github_com_alchemillahq_sylve_internal_db_models_vm.VMBootROM": { + "type": "string", + "enum": [ + "uefi", + "none" + ], + "x-enum-varnames": [ + "VMBootROMUEFI", + "VMBootROMNone" + ] + }, "github_com_alchemillahq_sylve_internal_db_models_vm.VMCPUPinning": { "type": "object", "properties": { @@ -10966,12 +11594,14 @@ "type": "string", "enum": [ "virtio-blk", + "virtio-9p", "ahci-hd", "ahci-cd", "nvme" ], "x-enum-varnames": [ "VirtIOStorageEmulation", + "VirtIO9PStorageEmulation", "AHCIHDStorageEmulation", "AHCICDStorageEmulation", "NVMEStorageEmulation" @@ -10982,12 +11612,14 @@ "enum": [ "raw", "zvol", - "image" + "image", + "filesystem" ], "x-enum-varnames": [ "VMStorageTypeRaw", "VMStorageTypeZVol", - "VMStorageTypeDiskImage" + "VMStorageTypeDiskImage", + "VMStorageTypeFilesystem" ] }, "github_com_alchemillahq_sylve_internal_db_models_zfs.PeriodicSnapshot": { @@ -11123,6 +11755,12 @@ "hostname": { "type": "string" }, + "jailTemplates": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.SimpleTemplateList" + } + }, "jails": { "type": "array", "items": { @@ -11132,6 +11770,12 @@ "nodeUUID": { "type": "string" }, + "vmTemplates": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.SimpleTemplateList" + } + }, "vms": { "type": "array", "items": { @@ -11415,6 +12059,69 @@ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapEntry": { + "type": "object", + "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" + } + } + }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.BootstrapRequest": { + "type": "object", + "required": [ + "major", + "pool", + "type" + ], + "properties": { + "major": { + "type": "integer" + }, + "minor": { + "type": "integer" + }, + "pool": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_jail.CreateJailRequest": { "type": "object", "required": [ @@ -11436,6 +12143,9 @@ "base": { "type": "string" }, + "bootstrapName": { + "type": "string" + }, "cleanEnvironment": { "type": "boolean" }, @@ -11616,6 +12326,20 @@ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_jail.SimpleTemplateList": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "sourceJailName": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_jail.State": { "type": "object", "properties": { @@ -11672,6 +12396,9 @@ "apic": { "type": "boolean" }, + "bootRom": { + "type": "string" + }, "cloudInit": { "type": "boolean" }, @@ -11702,6 +12429,12 @@ "description": { "type": "string" }, + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + }, "ignoreUMSR": { "type": "boolean" }, @@ -11762,6 +12495,12 @@ "tpmEmulation": { "type": "boolean" }, + "vncBind": { + "type": "string" + }, + "vncEnabled": { + "type": "boolean" + }, "vncPassword": { "type": "string" }, @@ -11826,6 +12565,9 @@ "vncResolution" ], "properties": { + "vncBind": { + "type": "string" + }, "vncEnabled": { "type": "boolean" }, @@ -11869,16 +12611,32 @@ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.SimpleTemplateList": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "sourceVmName": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.StorageEmulationType": { "type": "string", "enum": [ "virtio-blk", + "virtio-9p", "ahci-hd", "ahci-cd", "nvme" ], "x-enum-varnames": [ "VirtIOStorageEmulation", + "VirtIO9PStorageEmulation", "AHCIHDStorageEmulation", "AHCICDStorageEmulation", "NVMEStorageEmulation" @@ -11890,12 +12648,14 @@ "raw", "zvol", "image", + "filesystem", "none" ], "x-enum-varnames": [ "StorageTypeRaw", "StorageTypeZVOL", "StorageTypeDiskImage", + "StorageTypeFilesystem", "StorageTypeNone" ] }, @@ -11972,6 +12732,21 @@ } } }, + "github_com_alchemillahq_sylve_internal_interfaces_services_network.DeleteDynamicLeaseRequest": { + "type": "object", + "required": [ + "identifier", + "ip" + ], + "properties": { + "identifier": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, "github_com_alchemillahq_sylve_internal_interfaces_services_network.FileLeases": { "type": "object", "properties": { @@ -12373,6 +13148,9 @@ "datasetType": { "$ref": "#/definitions/gzfs.DatasetType" }, + "nameFilter": { + "type": "string" + }, "page": { "type": "integer" }, @@ -13223,20 +14001,56 @@ "type": "object", "required": [ "admin", - "password", "username" ], "properties": { "admin": { "type": "boolean" }, + "auxGroupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "disablePassword": { + "type": "boolean" + }, + "doasEnabled": { + "type": "boolean" + }, "email": { "type": "string" }, + "fullName": { + "type": "string" + }, + "homeDirPerms": { + "type": "integer" + }, + "homeDirectory": { + "type": "string" + }, + "locked": { + "type": "boolean" + }, + "newPrimaryGroup": { + "type": "boolean" + }, "password": { - "type": "string", - "maxLength": 128, - "minLength": 3 + "type": "string" + }, + "primaryGroupId": { + "type": "integer" + }, + "shell": { + "type": "string" + }, + "sshPublicKey": { + "type": "string" + }, + "uid": { + "type": "integer" }, "username": { "type": "string", @@ -13295,7 +14109,8 @@ "required": [ "clusterKey", "nodeId", - "nodeIp" + "nodeIp", + "nodeVersion" ], "properties": { "clusterKey": { @@ -13306,6 +14121,9 @@ }, "nodeIp": { "type": "string" + }, + "nodeVersion": { + "type": "string" } } }, @@ -13445,6 +14263,21 @@ } } }, + "internal_handlers_jail.JailEditNameRequest": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, "internal_handlers_jail.JailUpdateCPURequest": { "type": "object", "required": [ @@ -13548,6 +14381,14 @@ } } }, + "internal_handlers_jail.ModifyWakeOnLanRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "internal_handlers_jail.SetInheritanceRequest": { "type": "object", "properties": { @@ -13559,6 +14400,20 @@ } } }, + "internal_handlers_network.BulkDeleteNetworkObjectsRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, "internal_handlers_network.CreateManualSwitchRequest": { "type": "object", "required": [ @@ -13749,32 +14604,29 @@ "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": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, "internal_handlers_samba.SambaConfigRequest": { "type": "object", "properties": { + "appleExtensions": { + "type": "boolean" + }, "bindInterfacesOnly": { "type": "boolean" }, @@ -13792,6 +14644,144 @@ } } }, + "internal_handlers_samba.SambaGuestRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaGuestResponse": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "writeable": { + "type": "boolean" + } + } + }, + "internal_handlers_samba.SambaPermissionsRequest": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalIDsRequest" + } + } + }, + "internal_handlers_samba.SambaPermissionsResponse": { + "type": "object", + "properties": { + "read": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + }, + "write": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalSetResponse" + } + } + }, + "internal_handlers_samba.SambaPrincipalGroupResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaPrincipalIDsRequest": { + "type": "object", + "properties": { + "groupIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "userIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalSetResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalGroupResponse" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers_samba.SambaPrincipalUserResponse" + } + } + } + }, + "internal_handlers_samba.SambaPrincipalUserResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "internal_handlers_samba.SambaShareResponse": { + "type": "object", + "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" + } + } + }, "internal_handlers_samba.UpdateSambaShareRequest": { "type": "object", "properties": { @@ -13804,8 +14794,8 @@ "directoryMask": { "type": "string" }, - "guestOk": { - "type": "boolean" + "guest": { + "$ref": "#/definitions/internal_handlers_samba.SambaGuestRequest" }, "id": { "type": "integer" @@ -13813,20 +14803,14 @@ "name": { "type": "string" }, - "readOnly": { + "permissions": { + "$ref": "#/definitions/internal_handlers_samba.SambaPermissionsRequest" + }, + "timeMachine": { "type": "boolean" }, - "readOnlyGroups": { - "type": "array", - "items": { - "type": "string" - } - }, - "writeableGroups": { - "type": "array", - "items": { - "type": "string" - } + "timeMachineMaxSize": { + "type": "integer" } } }, @@ -13896,6 +14880,17 @@ } } }, + "internal_handlers_utilities.DownloadPathsResponse": { + "type": "object", + "properties": { + "http": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "internal_handlers_vm.ModifyBootOrderRequest": { "type": "object", "properties": { @@ -13907,6 +14902,14 @@ } } }, + "internal_handlers_vm.ModifyBootROMRequest": { + "type": "object", + "properties": { + "bootRom": { + "type": "string" + } + } + }, "internal_handlers_vm.ModifyClockRequest": { "type": "object", "properties": { @@ -13929,6 +14932,17 @@ } } }, + "internal_handlers_vm.ModifyExtraBhyveOptionsRequest": { + "type": "object", + "properties": { + "extraBhyveOptions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "internal_handlers_vm.ModifyIgnoreUMSRsRequest": { "type": "object", "properties": { @@ -14016,6 +15030,21 @@ } } }, + "internal_handlers_vm.VMEditNameRequest": { + "type": "object", + "required": [ + "name", + "rid" + ], + "properties": { + "name": { + "type": "string" + }, + "rid": { + "type": "integer" + } + } + }, "internal_handlers_zfs.BulkDeleteByNameRequest": { "type": "object", "required": [ diff --git a/internal/assets/swagger/swagger.yaml b/internal/assets/swagger/swagger.yaml index dd365f16..40df04b0 100644 --- a/internal/assets/swagger/swagger.yaml +++ b/internal/assets/swagger/swagger.yaml @@ -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: @@ -570,6 +583,17 @@ definitions: status: type: string type: object + github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_utilities_DownloadPathsResponse: + properties: + data: + $ref: '#/definitions/internal_handlers_utilities.DownloadPathsResponse' + error: + type: string + message: + type: string + status: + type: string + type: object github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_zfs_PoolStatPointResponse: properties: data: @@ -617,6 +641,9 @@ definitions: - samba-server - virtualization - wol-server + - firewall + - wireguard + - iscsi type: string x-enum-varnames: - DHCPServer @@ -624,6 +651,9 @@ definitions: - SambaServer - Virtualization - WoLServer + - Firewall + - WireGuard + - ISCSI github_com_alchemillahq_sylve_internal_db_models.BasicSettings: properties: id: @@ -694,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: @@ -925,6 +975,8 @@ definitions: $ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_jail.JailType' updatedAt: type: string + wol: + type: boolean type: object github_com_alchemillahq_sylve_internal_db_models_jail.JailHookPhase: enum: @@ -1145,6 +1197,8 @@ definitions: type: object github_com_alchemillahq_sylve_internal_db_models_network.Object: properties: + autoUpdate: + type: boolean createdAt: type: string description: @@ -1160,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 @@ -1195,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 @@ -1284,6 +1351,8 @@ definitions: type: object github_com_alchemillahq_sylve_internal_db_models_samba.SambaSettings: properties: + appleExtensions: + type: boolean bindInterfacesOnly: type: boolean id: @@ -1297,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: @@ -1464,12 +1502,18 @@ definitions: type: integer emulation: $ref: '#/definitions/github_com_alchemillahq_sylve_internal_db_models_vm.VMStorageEmulationType' + enable: + type: boolean + filesystemTarget: + type: string id: type: integer name: type: string pool: type: string + readOnly: + type: boolean recordSize: type: integer size: @@ -1497,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: @@ -1517,6 +1563,10 @@ definitions: type: string description: type: string + extraBhyveOptions: + items: + type: string + type: array id: type: integer ignoreUMSR: @@ -1565,6 +1615,8 @@ definitions: type: boolean updatedAt: type: string + vncBind: + type: string vncEnabled: type: boolean vncPassword: @@ -1578,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: @@ -1645,12 +1705,14 @@ definitions: github_com_alchemillahq_sylve_internal_db_models_vm.VMStorageEmulationType: enum: - virtio-blk + - virtio-9p - ahci-hd - ahci-cd - nvme type: string x-enum-varnames: - VirtIOStorageEmulation + - VirtIO9PStorageEmulation - AHCIHDStorageEmulation - AHCICDStorageEmulation - NVMEStorageEmulation @@ -1659,11 +1721,13 @@ definitions: - raw - zvol - image + - filesystem type: string x-enum-varnames: - VMStorageTypeRaw - VMStorageTypeZVol - VMStorageTypeDiskImage + - VMStorageTypeFilesystem github_com_alchemillahq_sylve_internal_db_models_zfs.PeriodicSnapshot: properties: createdAt: @@ -1754,12 +1818,20 @@ definitions: properties: hostname: type: string + jailTemplates: + items: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.SimpleTemplateList' + type: array jails: items: $ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_jail.SimpleList' type: array nodeUUID: type: string + vmTemplates: + items: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.SimpleTemplateList' + type: array vms: items: $ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.SimpleList' @@ -1952,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: @@ -1962,6 +2076,8 @@ definitions: type: array base: type: string + bootstrapName: + type: string cleanEnvironment: type: boolean cores: @@ -2086,6 +2202,15 @@ definitions: state: type: string type: object + github_com_alchemillahq_sylve_internal_interfaces_services_jail.SimpleTemplateList: + properties: + id: + type: integer + name: + type: string + sourceJailName: + type: string + type: object github_com_alchemillahq_sylve_internal_interfaces_services_jail.State: properties: ctId: @@ -2117,6 +2242,8 @@ definitions: type: boolean apic: type: boolean + bootRom: + type: string cloudInit: type: boolean cloudInitData: @@ -2137,6 +2264,10 @@ definitions: type: integer description: type: string + extraBhyveOptions: + items: + type: string + type: array ignoreUMSR: type: boolean iso: @@ -2177,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: @@ -2225,6 +2360,8 @@ definitions: type: object github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.ModifyVNCRequest: properties: + vncBind: + type: string vncEnabled: type: boolean vncPassword: @@ -2257,15 +2394,26 @@ definitions: vncPort: type: integer type: object + github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.SimpleTemplateList: + properties: + id: + type: integer + name: + type: string + sourceVmName: + type: string + type: object github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.StorageEmulationType: enum: - virtio-blk + - virtio-9p - ahci-hd - ahci-cd - nvme type: string x-enum-varnames: - VirtIOStorageEmulation + - VirtIO9PStorageEmulation - AHCIHDStorageEmulation - AHCICDStorageEmulation - NVMEStorageEmulation @@ -2274,12 +2422,14 @@ definitions: - raw - zvol - image + - filesystem - none type: string x-enum-varnames: - StorageTypeRaw - StorageTypeZVOL - StorageTypeDiskImage + - StorageTypeFilesystem - StorageTypeNone github_com_alchemillahq_sylve_internal_interfaces_services_libvirt.TimeOffset: enum: @@ -2330,6 +2480,16 @@ definitions: required: - dhcpRangeId type: object + github_com_alchemillahq_sylve_internal_interfaces_services_network.DeleteDynamicLeaseRequest: + properties: + identifier: + type: string + ip: + type: string + required: + - identifier + - ip + type: object github_com_alchemillahq_sylve_internal_interfaces_services_network.FileLeases: properties: clientId: @@ -2595,6 +2755,8 @@ definitions: type: array datasetType: $ref: '#/definitions/gzfs.DatasetType' + nameFilter: + type: string page: type: integer search: @@ -3167,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: @@ -3221,10 +3406,13 @@ definitions: type: string nodeIp: type: string + nodeVersion: + type: string required: - clusterKey - nodeId - nodeIp + - nodeVersion type: object internal_handlers_cluster.JoinClusterRequest: properties: @@ -3317,6 +3505,16 @@ definitions: required: - id type: object + internal_handlers_jail.JailEditNameRequest: + properties: + id: + type: integer + name: + type: string + required: + - id + - name + type: object internal_handlers_jail.JailUpdateCPURequest: properties: cores: @@ -3383,6 +3581,11 @@ definitions: resolvConf: type: string type: object + internal_handlers_jail.ModifyWakeOnLanRequest: + properties: + enabled: + type: boolean + type: object internal_handlers_jail.SetInheritanceRequest: properties: ipv4: @@ -3390,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: @@ -3517,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: @@ -3545,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: @@ -3553,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: @@ -3614,6 +3909,13 @@ definitions: type: string type: object type: object + internal_handlers_utilities.DownloadPathsResponse: + properties: + http: + type: string + path: + type: string + type: object internal_handlers_vm.ModifyBootOrderRequest: properties: bootOrder: @@ -3621,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: @@ -3635,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: @@ -3690,6 +4004,16 @@ definitions: required: - rid type: object + internal_handlers_vm.VMEditNameRequest: + properties: + name: + type: string + rid: + type: integer + required: + - name + - rid + type: object internal_handlers_zfs.BulkDeleteByNameRequest: properties: names: @@ -3890,7 +4214,7 @@ info: url: https://github.com/AlchemillaHQ/Sylve/blob/master/LICENSE termsOfService: https://github.com/AlchemillaHQ/Sylve/blob/master/LICENSE title: Sylve API - version: 0.1.1 + version: 0.2.3 paths: /auth/groups: get: @@ -5398,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: @@ -5482,6 +5915,34 @@ paths: summary: Update Jail Memory tags: - Jail + /jail/name: + put: + consumes: + - application/json + description: Update the name of a jail by its ID + parameters: + - description: Edit Jail Name Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers_jail.JailEditNameRequest' + 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' + security: + - BearerAuth: [] + summary: Edit a Jail's name + tags: + - Jail /jail/network: post: consumes: @@ -5996,6 +6457,38 @@ paths: summary: Delete DHCP Lease tags: - Network + /network/dhcp/lease/dynamic: + post: + consumes: + - application/json + description: Delete an active DHCP lease by identifier (MAC or DUID) and IP + parameters: + - description: Request Body + in: body + name: data + required: true + schema: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal_interfaces_services_network.DeleteDynamicLeaseRequest' + 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: Delete Dynamic DHCP Lease + tags: + - Network /network/dhcp/range: get: consumes: @@ -6243,9 +6736,9 @@ paths: - application/json responses: "200": - description: Samba share created successfully + description: ID of the created network object schema: - type: string + type: uint "400": description: Invalid request schema: @@ -6334,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: @@ -6574,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: @@ -6670,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: @@ -7115,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: @@ -8098,6 +8689,24 @@ paths: summary: Bulk Delete Downloads tags: - Utilities + /utilities/downloads/paths: + get: + consumes: + - application/json + description: Get configured filesystem paths used by downloader for HTTP and + Path downloads + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-internal_handlers_utilities_DownloadPathsResponse' + security: + - BearerAuth: [] + summary: Get Download Paths + tags: + - Utilities /utilities/downloads/signed-url: get: consumes: @@ -8383,6 +8992,69 @@ paths: summary: Get a Virtual Machine's Domain tags: - VM + /vm/logs/:rid: + get: + consumes: + - application/json + description: Retrieve console log for a specific VM by RID + parameters: + - description: VM RID + in: path + name: rid + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-string' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_alchemillahq_sylve_internal.APIResponse-any' + "404": + description: VM Not Found + 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: Get VM Logs + tags: + - VM + /vm/name: + put: + consumes: + - application/json + description: Update the name of a virtual machine by its RID + parameters: + - description: Edit Virtual Machine Name Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers_vm.VMEditNameRequest' + 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' + security: + - BearerAuth: [] + summary: Edit a Virtual Machine's name + tags: + - VM /vm/simple: get: consumes: diff --git a/internal/db/fixups.go b/internal/db/fixups.go index db0ab7bf..8e7de7d7 100644 --- a/internal/db/fixups.go +++ b/internal/db/fixups.go @@ -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" diff --git a/internal/db/models/samba/shares.go b/internal/db/models/samba/shares.go index a3768e99..f738c7c9 100644 --- a/internal/db/models/samba/shares.go +++ b/internal/db/models/samba/shares.go @@ -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'"` diff --git a/internal/handlers/samba/shares.go b/internal/handlers/samba/shares.go index b8b41236..a0379864 100644 --- a/internal/handlers/samba/shares.go +++ b/internal/handlers/samba/shares.go @@ -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(), diff --git a/internal/handlers/samba/shares_test.go b/internal/handlers/samba/shares_test.go new file mode 100644 index 00000000..5a7e923f --- /dev/null +++ b/internal/handlers/samba/shares_test.go @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// 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) + } +} diff --git a/internal/handlers/samba/test_helpers_test.go b/internal/handlers/samba/test_helpers_test.go new file mode 100644 index 00000000..14371715 --- /dev/null +++ b/internal/handlers/samba/test_helpers_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// 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) +} diff --git a/internal/services/samba/config.go b/internal/services/samba/config.go index ff04e0b0..e1139891 100644 --- a/internal/services/samba/config.go +++ b/internal/services/samba/config.go @@ -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 diff --git a/internal/services/samba/config_acl_guest_test.go b/internal/services/samba/config_acl_guest_test.go new file mode 100644 index 00000000..4c4f31c1 --- /dev/null +++ b/internal/services/samba/config_acl_guest_test.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: BSD-2-Clause +// +// Copyright (c) 2025 The FreeBSD Foundation. +// +// This software was developed by Hayzam Sherif +// of Alchemilla Ventures Pvt. Ltd. , +// 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") + } +} diff --git a/internal/services/samba/shares.go b/internal/services/samba/shares.go index c315ef5d..0cbaebb4 100644 --- a/internal/services/samba/shares.go +++ b/internal/services/samba/shares.go @@ -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) } diff --git a/internal/services/samba/shares_test.go b/internal/services/samba/shares_test.go index 72afd439..df21ec13 100644 --- a/internal/services/samba/shares_test.go +++ b/internal/services/samba/shares_test.go @@ -36,10 +36,12 @@ func TestCreateShareReturnsDatasetConflictBeforeDBDuplicate(t *testing.T) { "dataset-guid-1", nil, nil, - "0664", - "2775", + nil, + nil, true, false, + "0664", + "2775", false, 0, ) diff --git a/web/src/lib/api/samba/share.ts b/web/src/lib/api/samba/share.ts index eb1583ab..f5a5daef 100644 --- a/web/src/lib/api/samba/share.ts +++ b/web/src/lib/api/samba/share.ts @@ -10,22 +10,26 @@ export async function getSambaShares(): Promise { 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 { 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 { @@ -48,12 +56,10 @@ export async function updateSambaShare( id, name, dataset, - readOnlyGroups, - writeableGroups, + permissions, + guest, createMask, directoryMask, - guestOk, - readOnly, timeMachine, timeMachineMaxSize }); diff --git a/web/src/lib/components/custom/Samba/Share.svelte b/web/src/lib/components/custom/Samba/Share.svelte index 8f830d56..ceb79337 100644 --- a/web/src/lib/components/custom/Samba/Share.svelte +++ b/web/src/lib/components/custom/Samba/Share.svelte @@ -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(); }); @@ -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" - /> - - -
- - -
+
+ + +
+ + {#if !properties.guest.enabled} + + + + + + + + {:else} +
+ + +
+ {/if} -
-
- -
-
- - -
- -
- - -
{#if appleExtensions} -
+
+ -
+ + {#if properties.timeMachine} + + {/if} {/if}
- {#if appleExtensions && properties.timeMachine} - - {/if} - - -
- -
-
+
+ + +
diff --git a/web/src/lib/types/samba/shares.ts b/web/src/lib/types/samba/shares.ts index 8b475709..45a6bab6 100644 --- a/web/src/lib/types/samba/shares.ts +++ b/web/src/lib/types/samba/shares.ts @@ -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(), diff --git a/web/src/routes/[node]/storage/samba/shares/+page.svelte b/web/src/routes/[node]/storage/samba/shares/+page.svelte index 41270fcd..dbda5374 100644 --- a/web/src/routes/[node]/storage/samba/shares/+page.svelte +++ b/web/src/routes/[node]/storage/samba/shares/+page.svelte @@ -1,5 +1,6 @@