├── src ├── ES.SFTP │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── Messages │ │ ├── Events │ │ │ ├── ServerStartupEvent.cs │ │ │ ├── ConfigurationChanged.cs │ │ │ └── UserSessionStartedEvent.cs │ │ ├── Configuration │ │ │ └── SftpConfigurationRequest.cs │ │ └── Pam │ │ │ └── PamEventRequest.cs │ ├── Interop │ │ ├── ProcessRunOutput.cs │ │ └── ProcessUtil.cs │ ├── Configuration │ │ ├── Elements │ │ │ ├── LoggingDefinition.cs │ │ │ ├── HostKeysDefinition.cs │ │ │ ├── ChrootDefinition.cs │ │ │ ├── GroupDefinition.cs │ │ │ ├── HooksDefinition.cs │ │ │ ├── SftpConfiguration.cs │ │ │ ├── GlobalConfiguration.cs │ │ │ └── UserDefinition.cs │ │ └── ConfigurationService.cs │ ├── app.logging.Development.json │ ├── config │ │ ├── sssd.conf │ │ └── sftp.json │ ├── Extensions │ │ └── DirectoryInfoExtensions.cs │ ├── app.logging.json │ ├── Properties │ │ └── launchSettings.json │ ├── Api │ │ └── PamEventsController.cs │ ├── Dockerfile │ ├── SSH │ │ ├── Configuration │ │ │ ├── MatchBlock.cs │ │ │ └── SSHConfiguration.cs │ │ ├── HookRunner.cs │ │ ├── SessionHandler.cs │ │ └── SSHService.cs │ ├── Security │ │ ├── UserUtil.cs │ │ ├── GroupUtil.cs │ │ ├── AuthenticationService.cs │ │ └── UserManagementService.cs │ ├── ES.SFTP.csproj │ └── Program.cs ├── helm │ └── sftp │ │ ├── templates │ │ ├── NOTES.txt │ │ ├── config-secret.yaml │ │ ├── serviceaccount.yaml │ │ ├── service.yaml │ │ ├── _helpers.tpl │ │ └── deployment.yaml │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── LICENSE │ │ └── values.yaml ├── docker-compose │ ├── docker-compose.yaml │ └── docker-compose.override.dev.yaml ├── .dockerignore ├── ES.SFTP.sln └── ES.SFTP.sln.DotSettings ├── samples ├── hooks │ ├── onsessionchange │ └── onstartup ├── .ssh │ ├── id_demo2_ed25519.pub │ ├── id_demo2_ed25519 │ ├── id_demo2_rsa.pub │ ├── id_demo_rsa.pub │ ├── id_demo_rsa │ └── id_demo2_rsa ├── sample.sftp.json └── sample.dev.sftp.json ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── pipeline.yaml ├── .dockerignore ├── LICENSE ├── .gitignore └── README.md /src/ES.SFTP/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /src/ES.SFTP/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedHosts": "*" 3 | } -------------------------------------------------------------------------------- /samples/hooks/onsessionchange: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Session event '$1' for '$2'" -------------------------------------------------------------------------------- /samples/hooks/onstartup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "SSH service startup hook completed." -------------------------------------------------------------------------------- /samples/.ssh/id_demo2_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIi2wrsJnGEjfyH52+5A5JtRmkhXziftACa3tdc41vEt 2 | -------------------------------------------------------------------------------- /src/helm/sftp/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | SFTP can now be used for secure file transfers. You can connect to your service using any SFTP client. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /src/ES.SFTP/Messages/Events/ServerStartupEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace ES.SFTP.Messages.Events; 4 | 5 | public class ServerStartupEvent : INotification 6 | { 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Messages/Events/ConfigurationChanged.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace ES.SFTP.Messages.Events; 4 | 5 | public class ConfigurationChanged : INotification 6 | { 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Interop/ProcessRunOutput.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Interop; 2 | 3 | public class ProcessRunOutput 4 | { 5 | public string Output { get; set; } 6 | public int ExitCode { get; set; } 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/LoggingDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class LoggingDefinition 4 | { 5 | public bool IgnoreNoIdentificationString { get; set; } 6 | } -------------------------------------------------------------------------------- /src/ES.SFTP/app.logging.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/ES.SFTP/config/sssd.conf: -------------------------------------------------------------------------------- 1 | [sssd] 2 | config_file_version = 2 3 | services = nss, pam 4 | debug_level = 5 5 | 6 | [pam] 7 | 8 | [nss] 9 | fallback_homedir = /home/%u 10 | default_shell = /usr/sbin/nologin 11 | -------------------------------------------------------------------------------- /src/docker-compose/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | sftp: 4 | image: "emberstack/sftp" 5 | ports: 6 | - "22:22" 7 | volumes: 8 | - ../samples/sample.sftp.json:/app/config/sftp.json:ro -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/HostKeysDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class HostKeysDefinition 4 | { 5 | public string Ed25519 { get; set; } 6 | public string Rsa { get; set; } 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/ChrootDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class ChrootDefinition 4 | { 5 | public string Directory { get; set; } = "%h"; 6 | public string StartPath { get; set; } 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Messages/Configuration/SftpConfigurationRequest.cs: -------------------------------------------------------------------------------- 1 | using ES.SFTP.Configuration.Elements; 2 | using MediatR; 3 | 4 | namespace ES.SFTP.Messages.Configuration; 5 | 6 | public class SftpConfigurationRequest : IRequest 7 | { 8 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/GroupDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class GroupDefinition 4 | { 5 | public string Name { get; set; } 6 | public int? GID { get; set; } 7 | public List Users { get; set; } = new(); 8 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/HooksDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class HooksDefinition 4 | { 5 | public List OnServerStartup { get; set; } = new(); 6 | public List OnSessionChange { get; set; } = new(); 7 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Messages/Events/UserSessionStartedEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace ES.SFTP.Messages.Events; 4 | 5 | public class UserSessionChangedEvent : INotification 6 | { 7 | public string Username { get; set; } 8 | public string SessionState { get; set; } 9 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Messages/Pam/PamEventRequest.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace ES.SFTP.Messages.Pam; 4 | 5 | public class PamEventRequest : IRequest 6 | { 7 | public string Username { get; set; } 8 | public string EventType { get; set; } 9 | public string Service { get; set; } 10 | } -------------------------------------------------------------------------------- /samples/sample.sftp.json: -------------------------------------------------------------------------------- 1 | { 2 | "Global": { 3 | "Chroot": { 4 | "Directory": "%h", 5 | "StartPath": "sftp" 6 | }, 7 | "Directories": ["sftp"] 8 | }, 9 | "Users": [ 10 | { 11 | "Username": "demo", 12 | "Password": "demo" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/SftpConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class SftpConfiguration 4 | { 5 | public GlobalConfiguration Global { get; set; } = new(); 6 | public List Users { get; set; } = new(); 7 | public List Groups { get; set; } = new(); 8 | } -------------------------------------------------------------------------------- /src/helm/sftp/templates/config-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.configuration }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "sftp.fullname" . }} 6 | labels: 7 | {{- include "sftp.labels" . | nindent 4 }} 8 | type: Opaque 9 | stringData: 10 | sftp.json: |- 11 | {{ .Values.configuration | toPrettyJson | nindent 4 }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /src/helm/sftp/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "sftp.serviceAccountName" . }} 6 | labels: 7 | {{- include "sftp.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end -}} 13 | -------------------------------------------------------------------------------- /samples/.ssh/id_demo2_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACCItsK7CZxhI38h+dvuQOSbUZpIV84n7QAmt7XXONbxLQAAAIgMsBerDLAX 4 | qwAAAAtzc2gtZWQyNTUxOQAAACCItsK7CZxhI38h+dvuQOSbUZpIV84n7QAmt7XXONbxLQ 5 | AAAECGtcsqvGH3fXmxHiuFdK+qYJsJrTpHVP6CCEPnMGByDIi2wrsJnGEjfyH52+5A5JtR 6 | mkhXziftACa3tdc41vEtAAAAAAECAwQF 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/helm/sftp/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /src/ES.SFTP/Extensions/DirectoryInfoExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Extensions; 2 | 3 | public static class DirectoryInfoExtensions 4 | { 5 | public static bool IsDescendentOf(this DirectoryInfo directory, DirectoryInfo parent) 6 | { 7 | if (parent == null) return false; 8 | if (directory.Parent == null) return false; 9 | if (directory.Parent.FullName == parent.FullName) return true; 10 | return directory.Parent.IsDescendentOf(parent); 11 | } 12 | } -------------------------------------------------------------------------------- /src/docker-compose/docker-compose.override.dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | sftp: 4 | image: "emberstack/sftp:dev" 5 | build: 6 | context: ../ 7 | dockerfile: ES.SFTP.Host/Dockerfile 8 | ports: 9 | - "2222:22" 10 | volumes: 11 | - ../samples/sample.dev.sftp.json:/app/config/sftp.json:ro 12 | - ../samples/.ssh/id_demo2_rsa.pub:/home/demo2/.ssh/keys/id_rsa.pub:ro 13 | - ../samples/.ssh/id_demo2_ed25519.pub:/home/demo2/.ssh/keys/id_ed25519.pub:ro 14 | -------------------------------------------------------------------------------- /src/helm/sftp/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: sftp 3 | description: A Helm chart to deploy SFTP 4 | type: application 5 | version: 0.1.0 6 | appVersion: 0.1.0 7 | 8 | icon: https://raw.githubusercontent.com/emberstack/CDN/main/projects/docker-sftp/openssh.png 9 | keywords: 10 | - sftp 11 | - openssh 12 | - files 13 | - storage 14 | - ftp 15 | home: https://github.com/EmberStack/docker-sftp 16 | sources: 17 | - https://github.com/EmberStack/docker-sftp 18 | maintainers: 19 | - name: winromulus 20 | email: helm-charts@emberstack.com 21 | -------------------------------------------------------------------------------- /samples/.ssh/id_demo2_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC0k+rw4Z4C7VRsjLNbsywtkrRB3JDE0AOSn9U3AqF6fbId6SK1ft6G+XrJUqJCJSB7Z6kzIfwk+qr6/gQ4QrAJqQ/s5DGPn3B1fPU5T+B/U3HvMzPdKwktA9/fG+XxrT/HMjltxyCXO5W7EYYxjnE5cuF4ULHRuyJ2+wPBJIdxgWZFGnmakKLYJhhZ2taecN+O0vdjrUFsYJ54YR95+6zK3VBfDIDot7xsPhlsIMHrH/+ZeSR/sBFXfVVwA5oVqOQ8xEnw0EM7m1tHWmGs4+HbOxmkEVNm5P/a5V1p0nJSM8uW8MloomCCqwY8uGixOhkTcAe1iFUVKALPx+Xhyk/5xOdwBHgPjSTXV5toHU0b5l6MHTjao/bFI+EBVXmKidacBhVbluVDRsT7WR3TAPp/qPz9I2tEFZJM2tUe+vynueOdkXXKR6uzF/IeJvkAZqaG2tuqh7HpLvfAMY5RlFxMtvcDhzjVPAC0Bc16vcapS12hEc4LqwvIqqUVgotAbrU= winromulus@Maximus 2 | -------------------------------------------------------------------------------- /samples/.ssh/id_demo_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC++8/LkNAu1DPEjnBhzjTF3dkFY+jbRDIsQ/2JkGpRdEHmcCMBTMZGL9PCEEGjWo1Lfocfnk5hWrcloTMCh+rD5VCFNyPCEePK6nyEzZHwcQk9t6dWQwyjtLG8uAhVA30sl0Uw48YcNl9aF8FzpPMWnC7omM2VQPqq0Le05Hu50q0rW97z0vnxpQe+gdNhXOTq0FQ+J2wCwGc7Lxn5uXmZEozmZvlyFVEw6eFlyo9BwLluTHqd5wh9z+jx2U8dQfnIofrgd2Dp86tGNnvS59L/T/0llP8mbvTZNfusMJiO4gNNlsYhj4lQxUQaDL7gy9fxl8Pqf0eGnpOXluSMpAET1oFY5kKgHbfl6peepZzPQ77LQZDNDkrTwqc47VDNlxkdBmV9mp1R+C6no8Ws1Rkk+xYoNbXy6wVOEZy6VSydOy1OsUPpc1hMALYtkxNs88RBeVi/2uQZ8ssXwyKhTIs4zB0JXnSJbOrnkE/NiR8m6r7Nj21oRPcg0Jihl6gq7nU= winromulus@Maximus 2 | -------------------------------------------------------------------------------- /src/ES.SFTP/config/sftp.json: -------------------------------------------------------------------------------- 1 | { 2 | "Global": { 3 | "Chroot": { 4 | "Directory": "%h", 5 | "StartPath": "sftp" 6 | }, 7 | "Directories": ["sftp"], 8 | "Logging": { 9 | "IgnoreNoIdentificationString": true 10 | }, 11 | "Hooks": { 12 | "OnServerStartup": [], 13 | "OnSessionChange": [] 14 | } 15 | }, 16 | "Users": [ 17 | { 18 | "Username": "demo", 19 | "Password": "demo" 20 | } 21 | ], 22 | "Groups": [ 23 | { 24 | "Name": "demogroup", 25 | "Users": ["demo"], 26 | "GID": 5000 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /src/ES.SFTP/app.logging.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "Using": ["Serilog.Sinks.Console"], 4 | "MinimumLevel": { 5 | "Default": "Verbose", 6 | "Override": { 7 | "Microsoft.AspNetCore.Server.Kestrel": "Error", 8 | "Microsoft": "Warning", 9 | "System": "Warning", 10 | "Microsoft.Extensions.Http": "Warning" 11 | } 12 | }, 13 | "WriteTo": [ 14 | { 15 | "Name": "Console", 16 | "Args": { 17 | "outputTemplate": 18 | "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] ({SourceContext}) {Message:lj}{NewLine}{Exception}" 19 | } 20 | } 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/GlobalConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class GlobalConfiguration 4 | { 5 | public ChrootDefinition Chroot { get; set; } = new(); 6 | public List Directories { get; set; } = new(); 7 | public LoggingDefinition Logging { get; set; } = new(); 8 | public HostKeysDefinition HostKeys { get; set; } = new(); 9 | public HooksDefinition Hooks { get; set; } = new(); 10 | public string PKIandPassword { get; set; } 11 | 12 | public string Ciphers { get; set; } 13 | public string HostKeyAlgorithms { get; set; } 14 | public string KexAlgorithms { get; set; } 15 | public string MACs { get; set; } 16 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "HOST": { 5 | "commandName": "Project", 6 | "launchBrowser": false, 7 | "environmentVariables": { 8 | "SFTP_ENVIRONMENT": "Development" 9 | }, 10 | "applicationUrl": "http://0.0.0.0:25080" 11 | }, 12 | "Docker": { 13 | "commandName": "Docker", 14 | "launchBrowser": false, 15 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 16 | "publishAllPorts": true, 17 | "environmentVariables": { 18 | "SFTP_ENVIRONMENT": "Development" 19 | }, 20 | "httpPort": 56895 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/Elements/UserDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ES.SFTP.Configuration.Elements; 2 | 3 | public class UserDefinition 4 | { 5 | public string Username { get; set; } 6 | public string Password { get; set; } 7 | public bool PasswordIsEncrypted { get; set; } 8 | public List AllowedHosts { get; set; } = new(); 9 | 10 | // ReSharper disable once InconsistentNaming 11 | public int? UID { get; set; } 12 | 13 | // ReSharper disable once InconsistentNaming 14 | public int? GID { get; set; } 15 | public ChrootDefinition Chroot { get; set; } = new(); 16 | public List Directories { get; set; } = new(); 17 | public List PublicKeys { get; set; } = new(); 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 emberstack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/ES.SFTP/Api/PamEventsController.cs: -------------------------------------------------------------------------------- 1 | using ES.SFTP.Messages.Pam; 2 | using MediatR; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace ES.SFTP.Api; 6 | 7 | [Route("api/events/pam")] 8 | public class PamEventsController : Controller 9 | { 10 | private readonly ILogger _logger; 11 | private readonly IMediator _mediator; 12 | 13 | public PamEventsController(ILogger logger, IMediator mediator) 14 | { 15 | _logger = logger; 16 | _mediator = mediator; 17 | } 18 | 19 | 20 | [HttpGet] 21 | [Route("generic")] 22 | public async Task OnGenericPamEvent(string username, string type, string service) 23 | { 24 | _logger.LogDebug("Received event for user '{username}' with type '{type}', {service}", 25 | username, type, service); 26 | var response = await _mediator.Send(new PamEventRequest 27 | { 28 | Username = username, 29 | EventType = type, 30 | Service = service 31 | }); 32 | return response ? Ok() : BadRequest(); 33 | } 34 | } -------------------------------------------------------------------------------- /src/helm/sftp/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 emberstack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/ES.SFTP.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31710.8 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ES.SFTP", "ES.SFTP\ES.SFTP.csproj", "{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {A4012BFD-4BA0-416A-BFC2-4F7DEF32362B} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/helm/sftp/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "sftp.fullname" . }} 5 | labels: 6 | {{- include "sftp.labels" . | nindent 4 }} 7 | {{- with .Values.service.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | {{- if ne .Values.service.type "NodePort" }} 13 | {{- if .Values.service.clusterIP }} 14 | clusterIP: "{{ .Values.service.clusterIP }}" 15 | {{- end }} 16 | {{- end }} 17 | {{- if .Values.service.externalIPs }} 18 | externalIPs: 19 | {{ toYaml .Values.service.externalIPs | indent 4 }} 20 | {{- end }} 21 | {{- if .Values.service.loadBalancerIP }} 22 | loadBalancerIP: "{{ .Values.service.loadBalancerIP }}" 23 | {{- end }} 24 | {{- if .Values.service.loadBalancerSourceRanges }} 25 | loadBalancerSourceRanges: 26 | {{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }} 27 | {{- end }} 28 | {{- if and (semverCompare ">=1.7-0" .Capabilities.KubeVersion.GitVersion) (.Values.service.externalTrafficPolicy) }} 29 | externalTrafficPolicy: "{{ .Values.service.externalTrafficPolicy }}" 30 | {{- end }} 31 | type: {{ .Values.service.type }} 32 | ports: 33 | - port: {{ .Values.service.port }} 34 | targetPort: ssh 35 | protocol: TCP 36 | name: ssh 37 | selector: 38 | {{- include "sftp.selectorLabels" . | nindent 4 }} 39 | -------------------------------------------------------------------------------- /src/ES.SFTP.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | SFTP 3 | SSH 4 | SSHD 5 | SSSD 6 | True 7 | True 8 | True 9 | True 10 | True 11 | True -------------------------------------------------------------------------------- /src/ES.SFTP/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base 4 | RUN apt-get update && \ 5 | # - Install required packages 6 | # 7 | apt-get -y install members acl iputils-ping nano tini curl && \ 8 | # 9 | # - Install openssh-server 10 | apt-get -y install openssh-server && \ 11 | # 12 | # - Install sssd 13 | apt-get -y install sssd libpam-sss libnss-sss && \ 14 | # 15 | # - Cleanup 16 | rm -rf /var/lib/apt/lists/* && \ 17 | # 18 | # - Create OpenSSH directory 19 | mkdir -p /var/run/sshd && \ 20 | # 21 | # - Remove default host keys 22 | rm -f /etc/ssh/ssh_host_*key* 23 | WORKDIR /app 24 | EXPOSE 22 25080 25 | 26 | FROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim-amd64 AS build 27 | WORKDIR /src 28 | COPY ["ES.SFTP/ES.SFTP.csproj", "ES.SFTP/"] 29 | RUN dotnet restore "ES.SFTP/ES.SFTP.csproj" 30 | COPY . . 31 | WORKDIR "/src/ES.SFTP" 32 | RUN dotnet build "ES.SFTP.csproj" -c Release -o /app/build 33 | 34 | FROM build AS publish 35 | RUN dotnet publish "ES.SFTP.csproj" -c Release -o /app/publish 36 | 37 | FROM base AS final 38 | WORKDIR /app 39 | COPY --from=publish /app/publish . 40 | ENTRYPOINT ["tini", "--", "dotnet", "ES.SFTP.dll"] -------------------------------------------------------------------------------- /samples/sample.dev.sftp.json: -------------------------------------------------------------------------------- 1 | { 2 | "Global": { 3 | "Chroot": { 4 | "Directory": "%h", 5 | "StartPath": "sftp" 6 | }, 7 | "Directories": ["sftp"], 8 | "HostKeys": { 9 | "Ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBvlz4T2Fh9PKKeVhSupzXsBYVt44VJcb1554gRLKS2oAAAAIiJdbTtiXW0\n7QAAAAtzc2gtZWQyNTUxOQAAACBvlz4T2Fh9PKKeVhSupzXsBYVt44VJcb1554gRLKS2oA\nAAAEDI/igTE3dx3UC0As1d4kL0BNDaA3MkO9lDyWXqfErITm+XPhPYWH08op5WFK6nNewF\nhW3jhUlxvXnniBEspLagAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n" 10 | } 11 | }, 12 | "Users": [ 13 | { 14 | "Username": "demo", 15 | "Password": "demo", 16 | "PublicKeys": [ 17 | "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC++8/LkNAu1DPEjnBhzjTF3dkFY+jbRDIsQ/2JkGpRdEHmcCMBTMZGL9PCEEGjWo1Lfocfnk5hWrcloTMCh+rD5VCFNyPCEePK6nyEzZHwcQk9t6dWQwyjtLG8uAhVA30sl0Uw48YcNl9aF8FzpPMWnC7omM2VQPqq0Le05Hu50q0rW97z0vnxpQe+gdNhXOTq0FQ+J2wCwGc7Lxn5uXmZEozmZvlyFVEw6eFlyo9BwLluTHqd5wh9z+jx2U8dQfnIofrgd2Dp86tGNnvS59L/T/0llP8mbvTZNfusMJiO4gNNlsYhj4lQxUQaDL7gy9fxl8Pqf0eGnpOXluSMpAET1oFY5kKgHbfl6peepZzPQ77LQZDNDkrTwqc47VDNlxkdBmV9mp1R+C6no8Ws1Rkk+xYoNbXy6wVOEZy6VSydOy1OsUPpc1hMALYtkxNs88RBeVi/2uQZ8ssXwyKhTIs4zB0JXnSJbOrnkE/NiR8m6r7Nj21oRPcg0Jihl6gq7nU= winromulus@Maximus" 18 | ] 19 | }, 20 | { 21 | "Username": "demo2", 22 | "Password": "demo2" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/ES.SFTP/SSH/Configuration/MatchBlock.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace ES.SFTP.SSH.Configuration; 4 | 5 | public class MatchBlock 6 | { 7 | public enum MatchCriteria 8 | { 9 | All, 10 | User, 11 | Group 12 | } 13 | 14 | public MatchCriteria Criteria { get; set; } = MatchCriteria.All; 15 | 16 | public List Match { get; set; } = new(); 17 | public List Except { get; set; } = new(); 18 | public List Declarations { get; set; } = new(); 19 | 20 | private string GetPatternLine() 21 | { 22 | var builder = new StringBuilder(); 23 | builder.Append($"Match {Criteria} "); 24 | var patternList = (Match ?? new List()).Where(s => !string.IsNullOrWhiteSpace(s)) 25 | .Select(s => $"{s.Trim()}").Distinct().ToList(); 26 | patternList.AddRange((Except ?? new List()).Where(s => !string.IsNullOrWhiteSpace(s)) 27 | .Select(s => $"!{s.Trim()}").Distinct().ToList()); 28 | var exceptList = string.Join(",", patternList); 29 | if (!string.IsNullOrWhiteSpace(exceptList)) builder.Append($"\"{exceptList}\""); 30 | return builder.ToString(); 31 | } 32 | 33 | public override string ToString() 34 | { 35 | var builder = new StringBuilder(); 36 | builder.AppendLine(GetPatternLine()); 37 | foreach (var declaration in (Declarations ?? new List()).Where(declaration => 38 | !string.IsNullOrWhiteSpace(declaration))) 39 | builder.AppendLine(declaration?.Trim()); 40 | return builder.ToString(); 41 | } 42 | } -------------------------------------------------------------------------------- /src/helm/sftp/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for sftp. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: emberstack/sftp 9 | tag: "" 10 | pullPolicy: Always 11 | 12 | imagePullSecrets: [] 13 | nameOverride: "" 14 | fullnameOverride: "" 15 | 16 | configuration: null 17 | 18 | serviceAccount: 19 | # Specifies whether a service account should be created 20 | create: true 21 | # Annotations to add to the service account 22 | annotations: {} 23 | # The name of the service account to use. 24 | # If not set and create is true, a name is generated using the fullname template 25 | name: 26 | 27 | podSecurityContext: {} 28 | # fsGroup: 2000 29 | 30 | securityContext: {} 31 | # capabilities: 32 | # drop: 33 | # - ALL 34 | # readOnlyRootFilesystem: true 35 | # runAsNonRoot: true 36 | # runAsUser: 1000 37 | storage: 38 | volumeMounts: [] 39 | volumes: [] 40 | 41 | initContainers: [] 42 | 43 | service: 44 | type: ClusterIP 45 | port: 22 46 | 47 | resources: {} 48 | # We usually recommend not to specify default resources and to leave this as a conscious 49 | # choice for the user. This also increases chances charts run on environments with little 50 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 51 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 52 | # limits: 53 | # cpu: 100m 54 | # memory: 128Mi 55 | # requests: 56 | # cpu: 100m 57 | # memory: 128Mi 58 | 59 | nodeSelector: {} 60 | 61 | tolerations: [] 62 | 63 | affinity: {} 64 | -------------------------------------------------------------------------------- /src/ES.SFTP/Interop/ProcessUtil.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Text; 3 | 4 | namespace ES.SFTP.Interop; 5 | 6 | public class ProcessUtil 7 | { 8 | public static Task QuickRun(string filename, string arguments = null, 9 | bool throwOnError = true) 10 | { 11 | var outputStringBuilder = new StringBuilder(); 12 | var process = new Process 13 | { 14 | StartInfo = 15 | { 16 | FileName = filename, 17 | Arguments = arguments ?? string.Empty, 18 | UseShellExecute = false, 19 | RedirectStandardOutput = true, 20 | RedirectStandardError = true, 21 | CreateNoWindow = true 22 | } 23 | }; 24 | process.OutputDataReceived += (_, e) => outputStringBuilder.Append(e.Data); 25 | process.ErrorDataReceived += (_, e) => outputStringBuilder.Append(e.Data); 26 | try 27 | { 28 | process.Start(); 29 | process.BeginOutputReadLine(); 30 | process.BeginErrorReadLine(); 31 | process.WaitForExit(); 32 | } 33 | catch (Exception exception) 34 | { 35 | if (throwOnError) throw; 36 | return Task.FromResult(new ProcessRunOutput 37 | { 38 | ExitCode = 1, 39 | Output = exception.Message 40 | }); 41 | } 42 | 43 | var output = outputStringBuilder.ToString(); 44 | if (process.ExitCode != 0 && throwOnError) 45 | throw new Exception( 46 | $"Process failed with exit code '{process.ExitCode}.{Environment.NewLine}{output}'"); 47 | return Task.FromResult(new ProcessRunOutput 48 | { 49 | ExitCode = process.ExitCode, 50 | Output = output 51 | }); 52 | } 53 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Security/UserUtil.cs: -------------------------------------------------------------------------------- 1 | using ES.SFTP.Interop; 2 | 3 | namespace ES.SFTP.Security; 4 | 5 | public class UserUtil 6 | { 7 | public static async Task UserExists(string username) 8 | { 9 | var command = await ProcessUtil.QuickRun("getent", $"passwd {username}", false); 10 | return command.ExitCode == 0 && !string.IsNullOrWhiteSpace(command.Output); 11 | } 12 | 13 | public static async Task UserCreate(string username, bool noLoginShell = false, int? gid = null) 14 | { 15 | await ProcessUtil.QuickRun("useradd", 16 | $"--comment {username} {(noLoginShell ? "-s /usr/sbin/nologin " : string.Empty)}{(gid.HasValue ? "-g " + gid.Value + " " : string.Empty)}{username}"); 17 | } 18 | 19 | public static async Task UserDelete(string username, bool throwOnError = true) 20 | { 21 | await ProcessUtil.QuickRun("userdel", username, throwOnError); 22 | } 23 | 24 | public static async Task UserSetId(string username, int id, bool nonUnique = true) 25 | { 26 | await ProcessUtil.QuickRun("pkill", $"-U {await UserGetId(username)}", false); 27 | await ProcessUtil.QuickRun("usermod", 28 | $"{(nonUnique ? "--non-unique" : string.Empty)} --uid {id} {username}"); 29 | } 30 | 31 | public static async Task UserSetPassword(string username, string password, bool passwordIsEncrypted) 32 | { 33 | if (string.IsNullOrEmpty(password)) 34 | await ProcessUtil.QuickRun("usermod", $"-p \"*\" {username}"); 35 | else 36 | await ProcessUtil.QuickRun("bash", 37 | $"-c \"echo '{username}:{password}' | chpasswd {(passwordIsEncrypted ? "-e" : string.Empty)}\""); 38 | } 39 | 40 | public static async Task UserGetId(string username) 41 | { 42 | var command = await ProcessUtil.QuickRun("id", $"-u {username}"); 43 | return int.Parse(command.Output); 44 | } 45 | } -------------------------------------------------------------------------------- /src/helm/sftp/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "sftp.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "sftp.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "sftp.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "sftp.labels" -}} 38 | helm.sh/chart: {{ include "sftp.chart" . }} 39 | {{ include "sftp.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "sftp.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "sftp.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end -}} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "sftp.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create -}} 59 | {{ default (include "sftp.fullname" .) .Values.serviceAccount.name }} 60 | {{- else -}} 61 | {{ default "default" .Values.serviceAccount.name }} 62 | {{- end -}} 63 | {{- end -}} 64 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 7 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | - "[Status] Maybe Later" 18 | 19 | # Set to true to ignore issues in a project (defaults to false) 20 | exemptProjects: false 21 | 22 | # Set to true to ignore issues in a milestone (defaults to false) 23 | exemptMilestones: false 24 | 25 | # Set to true to ignore issues with an assignee (defaults to false) 26 | exemptAssignees: false 27 | 28 | # Label to use when marking as stale 29 | staleLabel: stale 30 | 31 | # Comment to post when marking as stale. Set to `false` to disable 32 | markComment: > 33 | Automatically marked as stale due to no recent activity. 34 | It will be closed if no further activity occurs. Thank you for your contributions. 35 | 36 | # Comment to post when removing the stale label. 37 | unmarkComment: > 38 | Removed stale label. 39 | 40 | # Comment to post when closing a stale Issue or Pull Request. 41 | closeComment: > 42 | Automatically closed stale item. 43 | 44 | # Limit the number of actions per hour, from 1-30. Default is 30 45 | limitPerRun: 30 46 | 47 | # Limit to only `issues` or `pulls` 48 | # only: issues 49 | 50 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 51 | # pulls: 52 | # daysUntilStale: 30 53 | # markComment: > 54 | # This pull request has been automatically marked as stale because it has not had 55 | # recent activity. It will be closed if no further activity occurs. Thank you 56 | # for your contributions. 57 | 58 | # issues: 59 | # exemptLabels: 60 | # - confirmed -------------------------------------------------------------------------------- /src/ES.SFTP/Security/GroupUtil.cs: -------------------------------------------------------------------------------- 1 | using ES.SFTP.Interop; 2 | 3 | namespace ES.SFTP.Security; 4 | 5 | public class GroupUtil 6 | { 7 | public static async Task GroupExists(string groupNameOrId) 8 | { 9 | var command = await ProcessUtil.QuickRun("getent", $"group {groupNameOrId}", false); 10 | return command.ExitCode == 0 && !string.IsNullOrWhiteSpace(command.Output); 11 | } 12 | 13 | public static async Task GroupCreate(string name, bool force = false, int? groupId = null, 14 | bool nonUniqueGroupId = true) 15 | { 16 | await ProcessUtil.QuickRun("groupadd", 17 | $"{(force ? "-f" : string.Empty)} {(groupId != null ? $"-g {groupId} {(nonUniqueGroupId ? "-o" : string.Empty)}" : string.Empty)} {name}"); 18 | } 19 | 20 | public static async Task GroupAddUser(string group, string username) 21 | { 22 | await ProcessUtil.QuickRun("usermod", $"-a -G {group} {username}"); 23 | } 24 | 25 | 26 | public static async Task GroupRemoveUser(string group, string username) 27 | { 28 | await ProcessUtil.QuickRun("usermod", $"-G {group} {username}"); 29 | } 30 | 31 | public static async Task> GroupListUsers(string group) 32 | { 33 | var command = await ProcessUtil.QuickRun("members", group, false); 34 | if (command.ExitCode != 0 && command.ExitCode != 1 && !string.IsNullOrWhiteSpace(command.Output)) 35 | throw new Exception($"Get group members command failed with exit code {command.ExitCode} and message:" + 36 | $"{Environment.NewLine}{command.Output}"); 37 | return command.Output.Split(' ', StringSplitOptions.RemoveEmptyEntries).OrderBy(s => s).ToList(); 38 | } 39 | 40 | public static async Task GroupGetId(string groupNameOrId) 41 | { 42 | var command = await ProcessUtil.QuickRun("getent", $"group {groupNameOrId}"); 43 | var groupEntryValues = command.Output.Split(":"); 44 | return int.Parse(groupEntryValues[2]); 45 | } 46 | 47 | public static async Task GroupSetId(string groupNameOrId, int id) 48 | { 49 | await ProcessUtil.QuickRun("groupmod", $"-g {id} {groupNameOrId}"); 50 | } 51 | } -------------------------------------------------------------------------------- /src/ES.SFTP/ES.SFTP.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | Linux 8 | emberstack/sftp:dev 9 | -p 2222:22 -p 25080:25080 --name sftpdev --privileged 10 | false 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | <_ContentIncludedByDefault Remove="app.logging.Development.json" /> 19 | <_ContentIncludedByDefault Remove="app.logging.json" /> 20 | <_ContentIncludedByDefault Remove="config\sftp.json" /> 21 | 22 | 23 | 24 | 25 | PreserveNewest 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | PreserveNewest 43 | true 44 | PreserveNewest 45 | 46 | 47 | PreserveNewest 48 | true 49 | PreserveNewest 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/ES.SFTP/SSH/Configuration/SSHConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace ES.SFTP.SSH.Configuration; 4 | 5 | public class SSHConfiguration 6 | { 7 | public List MatchBlocks { get; } = new(); 8 | 9 | public List AllowUsers { get; } = new(); 10 | 11 | public string Ciphers { get; set; } 12 | public string HostKeyAlgorithms { get; set; } 13 | public string KexAlgorithms { get; set; } 14 | public string MACs { get; set; } 15 | public string PKIandPassword { get; set; } 16 | 17 | public override string ToString() 18 | { 19 | var builder = new StringBuilder(); 20 | builder.AppendLine(); 21 | builder.AppendLine("UsePAM yes"); 22 | 23 | builder.AppendLine("# SSH Protocol"); 24 | builder.AppendLine("Protocol 2"); 25 | builder.AppendLine(); 26 | builder.AppendLine("# Host Keys"); 27 | builder.AppendLine("HostKey /etc/ssh/ssh_host_ed25519_key"); 28 | builder.AppendLine("HostKey /etc/ssh/ssh_host_rsa_key"); 29 | builder.AppendLine(); 30 | builder.AppendLine("# Cryptographic policy"); 31 | if (!string.IsNullOrWhiteSpace(Ciphers)) builder.AppendLine($"Ciphers {Ciphers}"); 32 | if (!string.IsNullOrWhiteSpace(HostKeyAlgorithms)) builder.AppendLine($"HostKeyAlgorithms {HostKeyAlgorithms}"); 33 | if (!string.IsNullOrWhiteSpace(KexAlgorithms)) builder.AppendLine($"KexAlgorithms {KexAlgorithms}"); 34 | if (!string.IsNullOrWhiteSpace(MACs)) builder.AppendLine($"MACs {MACs}"); 35 | builder.AppendLine(); 36 | builder.AppendLine("# Disable DNS for fast connections"); 37 | builder.AppendLine("UseDNS no"); 38 | builder.AppendLine(); 39 | builder.AppendLine("# Logging"); 40 | builder.AppendLine("LogLevel INFO"); 41 | builder.AppendLine(); 42 | builder.AppendLine("# Subsystem"); 43 | builder.AppendLine("Subsystem sftp internal-sftp"); 44 | builder.AppendLine(); 45 | builder.AppendLine("# Allowed users"); 46 | builder.AppendLine($"AllowUsers {string.Join(" ", AllowUsers)}"); 47 | builder.AppendLine(); 48 | if (PKIandPassword == "true") builder.AppendLine("AuthenticationMethods \"publickey,password\""); 49 | builder.AppendLine(); 50 | builder.AppendLine("# Match blocks"); 51 | foreach (var matchBlock in MatchBlocks) 52 | { 53 | builder.Append(matchBlock); 54 | builder.AppendLine(); 55 | } 56 | 57 | return builder.ToString(); 58 | } 59 | } -------------------------------------------------------------------------------- /samples/.ssh/id_demo_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /samples/.ssh/id_demo2_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /src/ES.SFTP/SSH/HookRunner.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using ES.SFTP.Interop; 3 | using ES.SFTP.Messages.Configuration; 4 | using ES.SFTP.Messages.Events; 5 | using MediatR; 6 | 7 | namespace ES.SFTP.SSH; 8 | 9 | public class HookRunner : INotificationHandler, INotificationHandler 10 | { 11 | private readonly ILogger _logger; 12 | private readonly IMediator _mediator; 13 | 14 | public HookRunner(ILogger logger, IMediator mediator) 15 | { 16 | _logger = logger; 17 | _mediator = mediator; 18 | } 19 | 20 | 21 | [SuppressMessage("ReSharper", "MethodSupportsCancellation")] 22 | public async Task Handle(ServerStartupEvent request, CancellationToken cancellationToken) 23 | { 24 | var sftpConfig = await _mediator.Send(new SftpConfigurationRequest()); 25 | var hooks = sftpConfig.Global.Hooks.OnServerStartup ?? new List(); 26 | foreach (var hook in hooks) await RunHook(hook); 27 | } 28 | 29 | 30 | [SuppressMessage("ReSharper", "MethodSupportsCancellation")] 31 | public async Task Handle(UserSessionChangedEvent request, CancellationToken cancellationToken) 32 | { 33 | var sftpConfig = await _mediator.Send(new SftpConfigurationRequest()); 34 | var hooks = sftpConfig.Global.Hooks.OnSessionChange ?? new List(); 35 | var args = string.Join(' ', request.SessionState, request.Username); 36 | foreach (var hook in hooks) await RunHook(hook, args); 37 | } 38 | 39 | private async Task RunHook(string hook, string args = null) 40 | { 41 | if (!File.Exists(hook)) 42 | { 43 | _logger.LogInformation("Hook '{hook}' does not exist", hook); 44 | return; 45 | } 46 | 47 | var execPermissionOutput = await ProcessUtil.QuickRun("bash", 48 | $"-c \"if [[ -x {hook} ]]; then echo 'true'; else echo 'false'; fi\"", false); 49 | 50 | if (execPermissionOutput.ExitCode != 0 || 51 | !bool.TryParse(execPermissionOutput.Output, out var isExecutable) || 52 | !isExecutable) 53 | await ProcessUtil.QuickRun("chmod", $"+x {hook}"); 54 | 55 | _logger.LogDebug("Executing hook '{hook}'", hook); 56 | var hookRun = await ProcessUtil.QuickRun(hook, args, false); 57 | 58 | if (string.IsNullOrWhiteSpace(hookRun.Output)) 59 | _logger.LogDebug("Hook '{hook}' completed with exit code {exitCode}.", hook, hookRun.ExitCode); 60 | else 61 | _logger.LogDebug( 62 | "Hook '{hook}' completed with exit code {exitCode}." + 63 | $"{Environment.NewLine}Output:{Environment.NewLine}{{output}}", 64 | hook, hookRun.ExitCode, hookRun.Output); 65 | } 66 | } -------------------------------------------------------------------------------- /src/helm/sftp/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "sftp.fullname" . }} 5 | labels: 6 | {{- include "sftp.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "sftp.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | labels: 15 | {{- include "sftp.selectorLabels" . | nindent 8 }} 16 | spec: 17 | {{- with .Values.imagePullSecrets }} 18 | imagePullSecrets: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | serviceAccountName: {{ include "sftp.serviceAccountName" . }} 22 | securityContext: 23 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 24 | {{- with .Values.initContainers }} 25 | initContainers: 26 | {{- toYaml . | nindent 8 }} 27 | {{- end }} 28 | containers: 29 | - name: {{ .Chart.Name }} 30 | securityContext: 31 | {{- toYaml .Values.securityContext | nindent 12 }} 32 | {{- if .Values.image.tag }} 33 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 34 | {{- else }} 35 | image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" 36 | {{- end }} 37 | imagePullPolicy: {{ .Values.image.pullPolicy }} 38 | ports: 39 | - name: ssh 40 | containerPort: 22 41 | protocol: TCP 42 | {{- if or .Values.configuration .Values.storage.volumeMounts }} 43 | volumeMounts: 44 | {{- if .Values.configuration }} 45 | - name: sftp-json 46 | mountPath: "/app/config/sftp.json" 47 | subPath: sftp.json 48 | readOnly: true 49 | {{- end }} 50 | {{- with .Values.storage.volumeMounts }} 51 | {{- toYaml . | nindent 12 }} 52 | {{- end }} 53 | {{- end }} 54 | livenessProbe: 55 | tcpSocket: 56 | port: ssh 57 | readinessProbe: 58 | tcpSocket: 59 | port: ssh 60 | resources: 61 | {{- toYaml .Values.resources | nindent 12 }} 62 | {{- if or .Values.configuration .Values.storage.volumes }} 63 | volumes: 64 | {{- if .Values.configuration }} 65 | - name: sftp-json 66 | secret: 67 | secretName: {{ include "sftp.fullname" . }} 68 | items: 69 | - key: sftp.json 70 | path: sftp.json 71 | {{- end }} 72 | {{- with .Values.storage.volumes }} 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- end }} 76 | {{- with .Values.nodeSelector }} 77 | nodeSelector: 78 | {{- toYaml . | nindent 8 }} 79 | {{- end }} 80 | {{- with .Values.affinity }} 81 | affinity: 82 | {{- toYaml . | nindent 8 }} 83 | {{- end }} 84 | {{- with .Values.tolerations }} 85 | tolerations: 86 | {{- toYaml . | nindent 8 }} 87 | {{- end }} 88 | -------------------------------------------------------------------------------- /src/ES.SFTP/Security/AuthenticationService.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using ES.SFTP.Interop; 3 | 4 | namespace ES.SFTP.Security; 5 | 6 | public class AuthenticationService : IHostedService 7 | { 8 | private const string PamDirPath = "/etc/pam.d"; 9 | private const string PamHookName = "sftp-hook"; 10 | private readonly ILogger _logger; 11 | 12 | public AuthenticationService(ILogger logger) 13 | { 14 | _logger = logger; 15 | } // ReSharper disable MethodSupportsCancellation 16 | public async Task StartAsync(CancellationToken cancellationToken) 17 | { 18 | _logger.LogDebug("Starting"); 19 | 20 | var pamCommonSessionFile = Path.Combine(PamDirPath, "common-session"); 21 | var pamSftpHookFile = Path.Combine(PamDirPath, PamHookName); 22 | 23 | _logger.LogDebug("Stopping SSSD service"); 24 | await ProcessUtil.QuickRun("service", "sssd stop", false); 25 | 26 | _logger.LogDebug("Applying SSSD configuration"); 27 | File.Copy("./config/sssd.conf", "/etc/sssd/sssd.conf", true); 28 | await ProcessUtil.QuickRun("chown", "root:root \"/etc/sssd/sssd.conf\""); 29 | await ProcessUtil.QuickRun("chmod", "600 \"/etc/sssd/sssd.conf\""); 30 | 31 | _logger.LogDebug("Installing PAM hook"); 32 | var scriptsDirectory = Path.Combine(PamDirPath, "scripts"); 33 | if (!Directory.Exists(scriptsDirectory)) Directory.CreateDirectory(scriptsDirectory); 34 | var hookScriptFile = Path.Combine(new DirectoryInfo(scriptsDirectory).FullName, "sftp-pam-event.sh"); 35 | var eventsScriptBuilder = new StringBuilder(); 36 | eventsScriptBuilder.AppendLine("#!/bin/sh"); 37 | eventsScriptBuilder.AppendLine( 38 | "curl \"http://localhost:25080/api/events/pam/generic?username=$PAM_USER&type=$PAM_TYPE&service=$PAM_SERVICE\""); 39 | await File.WriteAllTextAsync(hookScriptFile, eventsScriptBuilder.ToString()); 40 | await ProcessUtil.QuickRun("chown", $"root:root \"{hookScriptFile}\""); 41 | await ProcessUtil.QuickRun("chmod", $"+x \"{hookScriptFile}\""); 42 | 43 | 44 | var hookBuilder = new StringBuilder(); 45 | hookBuilder.AppendLine("# This file is used to signal the SFTP service on user events."); 46 | hookBuilder.AppendLine($"session required pam_exec.so {new FileInfo(hookScriptFile).FullName}"); 47 | await File.WriteAllTextAsync(pamSftpHookFile, hookBuilder.ToString()); 48 | await ProcessUtil.QuickRun("chown", $"root:root \"{pamSftpHookFile}\""); 49 | await ProcessUtil.QuickRun("chmod", $"644 \"{pamSftpHookFile}\""); 50 | 51 | 52 | if (!(await File.ReadAllTextAsync(pamCommonSessionFile)).Contains($"@include {PamHookName}")) 53 | await File.AppendAllTextAsync(pamCommonSessionFile, $"@include {PamHookName}{Environment.NewLine}"); 54 | 55 | _logger.LogDebug("Restarting SSSD service"); 56 | await ProcessUtil.QuickRun("service", "sssd restart", false); 57 | 58 | _logger.LogInformation("Started"); 59 | } 60 | 61 | public async Task StopAsync(CancellationToken cancellationToken) 62 | { 63 | _logger.LogDebug("Stopping"); 64 | await ProcessUtil.QuickRun("service", "sssd stop", false); 65 | _logger.LogInformation("Stopped"); 66 | } 67 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Autofac; 3 | using Autofac.Extensions.DependencyInjection; 4 | using ES.SFTP.Configuration; 5 | using ES.SFTP.Configuration.Elements; 6 | using ES.SFTP.Security; 7 | using ES.SFTP.SSH; 8 | using MediatR; 9 | using MediatR.Pipeline; 10 | using Serilog; 11 | 12 | Log.Logger = new LoggerConfiguration() 13 | .ReadFrom.Configuration(new ConfigurationBuilder() 14 | .SetBasePath(Directory.GetCurrentDirectory()) 15 | .AddJsonFile("app.logging.json") 16 | .AddEnvironmentVariables(nameof(ES)) 17 | .AddCommandLine(args) 18 | .Build()) 19 | .CreateLogger(); 20 | 21 | 22 | try 23 | { 24 | Log.Information("Starting host"); 25 | 26 | var builder = WebApplication.CreateBuilder(args); 27 | builder.Environment.EnvironmentName = 28 | Environment.GetEnvironmentVariable($"{nameof(ES)}_{nameof(Environment)}") ?? 29 | Environments.Production; 30 | 31 | builder.Configuration.AddJsonFile("app.logging.json", false, false); 32 | builder.Configuration.AddJsonFile("config/sftp.json", false, true); 33 | builder.Configuration.AddEnvironmentVariables(nameof(ES)); 34 | builder.Configuration.AddCommandLine(args); 35 | 36 | builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); 37 | builder.Host.UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration 38 | .ReadFrom.Configuration(hostingContext.Configuration) 39 | .Enrich.FromLogContext(), true); 40 | builder.Host.UseConsoleLifetime(); 41 | 42 | 43 | builder.Services.AddHttpClient(); 44 | builder.Services.AddOptions(); 45 | builder.Services.AddHealthChecks(); 46 | builder.Services.AddMediatR(typeof(void).Assembly); 47 | builder.Services.AddControllers(); 48 | 49 | builder.Services.Configure(builder.Configuration); 50 | 51 | builder.Host.ConfigureContainer((ContainerBuilder container) => 52 | { 53 | container.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly).AsImplementedInterfaces(); 54 | container.RegisterGeneric(typeof(RequestPostProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>)); 55 | container.RegisterGeneric(typeof(RequestPreProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>)); 56 | container.Register(ctx => 57 | { 58 | var c = ctx.Resolve(); 59 | return t => c.Resolve(t); 60 | }); 61 | 62 | 63 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 64 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 65 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 66 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 67 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 68 | container.RegisterType().AsImplementedInterfaces().SingleInstance(); 69 | }); 70 | 71 | builder.WebHost.ConfigureKestrel(options => { options.ListenLocalhost(25080); }); 72 | 73 | 74 | var app = builder.Build(); 75 | 76 | if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error"); 77 | 78 | app.UseStaticFiles(); 79 | app.UseRouting(); 80 | app.UseAuthorization(); 81 | app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); 82 | 83 | 84 | await app.RunAsync(); 85 | return 0; 86 | } 87 | catch (Exception ex) 88 | { 89 | Log.Fatal(ex, "Host terminated unexpectedly"); 90 | return 1; 91 | } 92 | finally 93 | { 94 | Log.CloseAndFlush(); 95 | } -------------------------------------------------------------------------------- /src/ES.SFTP/Configuration/ConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using ES.SFTP.Configuration.Elements; 2 | using ES.SFTP.Messages.Configuration; 3 | using ES.SFTP.Messages.Events; 4 | using MediatR; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace ES.SFTP.Configuration; 8 | 9 | public class ConfigurationService : IHostedService, IRequestHandler 10 | { 11 | private readonly ILogger _logger; 12 | private readonly IMediator _mediator; 13 | private readonly IOptionsMonitor _sftpOptionsMonitor; 14 | private SftpConfiguration _config; 15 | private IDisposable _sftpOptionsMonitorChangeHandler; 16 | 17 | 18 | public ConfigurationService(ILogger logger, 19 | IOptionsMonitor sftpOptionsMonitor, 20 | IMediator mediator) 21 | { 22 | _logger = logger; 23 | _sftpOptionsMonitor = sftpOptionsMonitor; 24 | _mediator = mediator; 25 | } 26 | 27 | 28 | public async Task StartAsync(CancellationToken cancellationToken) 29 | { 30 | _logger.LogDebug("Starting"); 31 | _sftpOptionsMonitorChangeHandler = _sftpOptionsMonitor.OnChange(OnSftpConfigurationChanged); 32 | await UpdateConfiguration(); 33 | 34 | _logger.LogInformation("Started"); 35 | } 36 | 37 | public Task StopAsync(CancellationToken cancellationToken) 38 | { 39 | _logger.LogDebug("Stopping"); 40 | 41 | _sftpOptionsMonitorChangeHandler?.Dispose(); 42 | _logger.LogInformation("Stopped"); 43 | 44 | return Task.CompletedTask; 45 | } 46 | 47 | 48 | public Task Handle(SftpConfigurationRequest request, CancellationToken cancellationToken) 49 | { 50 | return Task.FromResult(_config); 51 | } 52 | 53 | private void OnSftpConfigurationChanged(SftpConfiguration arg1, string arg2) 54 | { 55 | _logger.LogInformation("SFTP Configuration was changed."); 56 | UpdateConfiguration().Wait(); 57 | _mediator.Publish(new ConfigurationChanged()).ConfigureAwait(false); 58 | } 59 | 60 | private Task UpdateConfiguration() 61 | { 62 | _logger.LogDebug("Validating and updating configuration"); 63 | 64 | var config = _sftpOptionsMonitor.CurrentValue ?? new SftpConfiguration(); 65 | 66 | config.Global ??= new GlobalConfiguration(); 67 | 68 | config.Global.Directories ??= new List(); 69 | config.Global.Logging ??= new LoggingDefinition(); 70 | config.Global.Chroot ??= new ChrootDefinition(); 71 | config.Global.PKIandPassword ??= new string(""); 72 | config.Global.HostKeys ??= new HostKeysDefinition(); 73 | config.Global.Hooks ??= new HooksDefinition(); 74 | 75 | if (string.IsNullOrWhiteSpace(config.Global.Chroot.Directory)) config.Global.Chroot.Directory = "%h"; 76 | if (string.IsNullOrWhiteSpace(config.Global.Chroot.StartPath)) config.Global.Chroot.StartPath = null; 77 | 78 | 79 | config.Users ??= new List(); 80 | 81 | var validUsers = new List(); 82 | for (var index = 0; index < config.Users.Count; index++) 83 | { 84 | var userDefinition = config.Users[index]; 85 | if (string.IsNullOrWhiteSpace(userDefinition.Username)) 86 | { 87 | _logger.LogWarning("Users[{index}] has a null or whitespace username. Skipping user.", index); 88 | continue; 89 | } 90 | 91 | userDefinition.Chroot ??= new ChrootDefinition(); 92 | if (string.IsNullOrWhiteSpace(userDefinition.Chroot.Directory)) 93 | userDefinition.Chroot.Directory = config.Global.Chroot.Directory; 94 | if (string.IsNullOrWhiteSpace(userDefinition.Chroot.StartPath)) 95 | userDefinition.Chroot.StartPath = config.Global.Chroot.StartPath; 96 | 97 | if (userDefinition.Chroot.Directory == config.Global.Chroot.Directory && 98 | userDefinition.Chroot.StartPath == config.Global.Chroot.StartPath) 99 | userDefinition.Chroot = null; 100 | userDefinition.Directories ??= new List(); 101 | 102 | validUsers.Add(userDefinition); 103 | } 104 | 105 | config.Users = validUsers; 106 | _logger.LogInformation("Configuration contains '{userCount}' user(s)", config.Users.Count); 107 | 108 | _config = config; 109 | return Task.CompletedTask; 110 | } 111 | } -------------------------------------------------------------------------------- /src/ES.SFTP/SSH/SessionHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using ES.SFTP.Configuration.Elements; 3 | using ES.SFTP.Extensions; 4 | using ES.SFTP.Interop; 5 | using ES.SFTP.Messages.Configuration; 6 | using ES.SFTP.Messages.Events; 7 | using ES.SFTP.Messages.Pam; 8 | using MediatR; 9 | 10 | namespace ES.SFTP.SSH; 11 | 12 | public class SessionHandler : IRequestHandler 13 | { 14 | private const string HomeBasePath = "/home"; 15 | private const string SftpUserInventoryGroup = "sftp-user-inventory"; 16 | 17 | private readonly ILogger _logger; 18 | private readonly IMediator _mediator; 19 | private SftpConfiguration _config; 20 | 21 | public SessionHandler(ILogger logger, IMediator mediator) 22 | { 23 | _logger = logger; 24 | _mediator = mediator; 25 | } 26 | 27 | 28 | [SuppressMessage("ReSharper", "MethodSupportsCancellation")] 29 | public async Task Handle(PamEventRequest request, CancellationToken cancellationToken) 30 | { 31 | switch (request.EventType) 32 | { 33 | case "open_session": 34 | await PrepareUserForSftp(request.Username); 35 | break; 36 | } 37 | 38 | await _mediator.Publish(new UserSessionChangedEvent 39 | { 40 | Username = request.Username, 41 | SessionState = request.EventType 42 | }); 43 | return true; 44 | } 45 | 46 | private async Task PrepareUserForSftp(string username) 47 | { 48 | _logger.LogDebug("Configuring session for user '{user}'", username); 49 | 50 | _config = await _mediator.Send(new SftpConfigurationRequest()); 51 | 52 | var user = _config.Users.FirstOrDefault(s => s.Username == username) ?? new UserDefinition 53 | { 54 | Username = username, 55 | Chroot = _config.Global.Chroot, 56 | Directories = _config.Global.Directories 57 | }; 58 | 59 | var homeDirPath = Path.Combine(HomeBasePath, username); 60 | var chroot = user.Chroot ?? _config.Global.Chroot; 61 | 62 | //Parse chroot path by replacing markers 63 | var chrootPath = string.Join("%%h", 64 | chroot.Directory.Split("%%h") 65 | .Select(s => s.Replace("%h", homeDirPath)).ToList()); 66 | chrootPath = string.Join("%%u", 67 | chrootPath.Split("%%u") 68 | .Select(s => s.Replace("%u", username)).ToList()); 69 | 70 | //Create chroot directory and set owner to root and correct permissions 71 | var chrootDirectory = Directory.CreateDirectory(chrootPath); 72 | await ProcessUtil.QuickRun("chown", $"root:root {chrootDirectory.FullName}"); 73 | await ProcessUtil.QuickRun("chmod", $"755 {chrootDirectory.FullName}"); 74 | 75 | var directories = new List(); 76 | directories.AddRange(_config.Global.Directories); 77 | directories.AddRange(user.Directories); 78 | foreach (var directory in directories.Distinct().OrderBy(s => s).ToList()) 79 | { 80 | var dirInfo = new DirectoryInfo(Path.Combine(chrootDirectory.FullName, directory)); 81 | if (!dirInfo.Exists) 82 | { 83 | _logger.LogDebug("Creating directory '{dir}' for user '{user}'", dirInfo.FullName, username); 84 | Directory.CreateDirectory(dirInfo.FullName); 85 | } 86 | 87 | try 88 | { 89 | if (dirInfo.IsDescendentOf(chrootDirectory)) 90 | { 91 | //Set the user as owner for directory and all parents until chroot path 92 | var dir = dirInfo; 93 | while (dir.FullName != chrootDirectory.FullName) 94 | { 95 | await ProcessUtil.QuickRun("chown", $"{username}:{SftpUserInventoryGroup} {dir.FullName}"); 96 | dir = dir.Parent ?? chrootDirectory; 97 | } 98 | } 99 | else 100 | { 101 | _logger.LogWarning( 102 | "Directory '{dir}' is not within chroot path '{chroot}'. Setting direct permissions.", 103 | dirInfo.FullName, chrootDirectory.FullName); 104 | 105 | await ProcessUtil.QuickRun("chown", 106 | $"{username}:{SftpUserInventoryGroup} {dirInfo.FullName}"); 107 | } 108 | } 109 | catch (Exception exception) 110 | { 111 | _logger.LogWarning(exception, "Exception occured while setting permissions for '{dir}' ", 112 | dirInfo.FullName); 113 | } 114 | } 115 | 116 | _logger.LogInformation("Session ready for user '{user}'", username); 117 | } 118 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | # **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.sln.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yaml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | push: 5 | paths: 6 | - "src/**" 7 | - ".github/workflows/**" 8 | pull_request: 9 | paths: 10 | - "src/**" 11 | - ".github/workflows/**" 12 | 13 | env: 14 | version: 5.1.${{github.run_number}} 15 | imageRepository: "emberstack/sftp" 16 | DOCKER_CLI_EXPERIMENTAL: "enabled" 17 | 18 | 19 | jobs: 20 | ci: 21 | name: CI 22 | runs-on: ubuntu-latest 23 | steps: 24 | 25 | - name: tools - helm - install 26 | uses: azure/setup-helm@v1 27 | 28 | - name: checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: artifacts - prepare directories 32 | run: | 33 | mkdir -p .artifacts/helm 34 | 35 | - name: helm - import README 36 | run: cp README.md src/helm/sftp/README.md 37 | 38 | - name: helm - package chart 39 | run: helm package --destination .artifacts/helm --version ${{env.version}} --app-version ${{env.version}} src/helm/sftp 40 | 41 | - name: "artifacts - upload - helm chart" 42 | uses: actions/upload-artifact@v2 43 | with: 44 | name: helm 45 | path: .artifacts/helm 46 | 47 | - name: tools - docker - login 48 | if: github.event_name == 'push' 49 | uses: docker/login-action@v1 50 | with: 51 | username: ${{ secrets.ES_DOCKERHUB_USERNAME }} 52 | password: ${{ secrets.ES_DOCKERHUB_PAT }} 53 | 54 | - name: "docker - buildx prepare" 55 | run: | 56 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 57 | docker buildx create --name builder --driver docker-container --use 58 | docker buildx inspect --bootstrap 59 | 60 | 61 | 62 | - name: "docker - build PR" 63 | if: github.event_name == 'pull_request' 64 | run: | 65 | docker buildx build --platform linux/amd64 -t ${{env.imageRepository}}:build-${{env.version}}-amd64 -f src/ES.SFTP/Dockerfile src/ 66 | docker buildx build --platform linux/arm -t ${{env.imageRepository}}:build-${{env.version}}-arm32v7 -f src/ES.SFTP/Dockerfile src/ 67 | docker buildx build --platform linux/arm64 -t ${{env.imageRepository}}:build-${{env.version}}-arm64v8 -f src/ES.SFTP/Dockerfile src/ 68 | 69 | 70 | 71 | - name: "docker - build and publish - amd64" 72 | if: github.event_name == 'push' 73 | run: | 74 | docker buildx build --push --platform linux/amd64 --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-amd64 -f src/ES.SFTP/Dockerfile src/ 75 | 76 | - name: "docker - build and publish - arm32v7" 77 | if: github.event_name == 'push' 78 | run: | 79 | docker buildx build --push --platform linux/arm --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-arm32v7 -f src/ES.SFTP/Dockerfile src/ 80 | 81 | - name: "docker - build and publish - arm64v8" 82 | if: github.event_name == 'push' 83 | run: | 84 | docker buildx build --push --platform linux/arm64 --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-arm64v8 -f src/ES.SFTP/Dockerfile src/ 85 | 86 | 87 | - name: "docker - create manifest and publish" 88 | if: github.event_name == 'push' 89 | run: | 90 | docker pull --platform linux/amd64 ${{env.imageRepository}}:build-${{env.version}}-amd64 91 | docker pull --platform linux/arm/v7 ${{env.imageRepository}}:build-${{env.version}}-arm32v7 92 | docker pull --platform linux/arm64 ${{env.imageRepository}}:build-${{env.version}}-arm64v8 93 | docker manifest create ${{env.imageRepository}}:build-${{env.version}} ${{env.imageRepository}}:build-${{env.version}}-amd64 ${{env.imageRepository}}:build-${{env.version}}-arm32v7 ${{env.imageRepository}}:build-${{env.version}}-arm64v8 94 | docker manifest inspect ${{env.imageRepository}}:build-${{env.version}} 95 | docker manifest push ${{env.imageRepository}}:build-${{env.version}} 96 | 97 | cd: 98 | name: CD 99 | needs: ci 100 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: tools - helm - install 104 | uses: azure/setup-helm@v1 105 | 106 | - name: tools - docker - login 107 | uses: docker/login-action@v1 108 | with: 109 | username: ${{ secrets.ES_DOCKERHUB_USERNAME }} 110 | password: ${{ secrets.ES_DOCKERHUB_PAT }} 111 | 112 | - name: artifacts - download - helm chart 113 | uses: actions/download-artifact@v2 114 | with: 115 | name: helm 116 | path: .artifacts/helm 117 | 118 | - name: "docker - create manifest and publish" 119 | run: | 120 | docker pull ${{env.imageRepository}}:build-${{env.version}}-amd64 121 | docker pull ${{env.imageRepository}}:build-${{env.version}}-arm32v7 122 | docker pull ${{env.imageRepository}}:build-${{env.version}}-arm64v8 123 | docker manifest create ${{env.imageRepository}}:${{env.version}} ${{env.imageRepository}}:build-${{env.version}}-amd64 ${{env.imageRepository}}:build-${{env.version}}-arm32v7 ${{env.imageRepository}}:build-${{env.version}}-arm64v8 124 | docker manifest create ${{env.imageRepository}}:latest ${{env.imageRepository}}:build-${{env.version}}-amd64 ${{env.imageRepository}}:build-${{env.version}}-arm32v7 ${{env.imageRepository}}:build-${{env.version}}-arm64v8 125 | docker manifest push ${{env.imageRepository}}:${{env.version}} 126 | docker manifest push ${{env.imageRepository}}:latest 127 | docker manifest push ${{env.imageRepository}}:${{env.version}} 128 | docker manifest push ${{env.imageRepository}}:latest 129 | docker tag ${{env.imageRepository}}:build-${{env.version}}-amd64 ${{env.imageRepository}}:${{env.version}}-amd64 130 | docker tag ${{env.imageRepository}}:build-${{env.version}}-arm32v7 ${{env.imageRepository}}:${{env.version}}-arm32v7 131 | docker tag ${{env.imageRepository}}:build-${{env.version}}-arm64v8 ${{env.imageRepository}}:${{env.version}}-arm64v8 132 | docker push ${{env.imageRepository}}:${{env.version}}-amd64 133 | docker push ${{env.imageRepository}}:${{env.version}}-arm32v7 134 | docker push ${{env.imageRepository}}:${{env.version}}-arm64v8 135 | 136 | - name: github - checkout - helm-charts 137 | uses: actions/checkout@v2 138 | with: 139 | repository: emberstack/helm-charts 140 | token: ${{ secrets.ES_GITHUB_PAT }} 141 | path: helm-charts 142 | ref: main 143 | 144 | 145 | - name: github - publish - chart 146 | run: | 147 | mkdir -p helm-charts/repository/sftp 148 | cp .artifacts/helm/sftp-${{env.version}}.tgz helm-charts/repository/sftp 149 | 150 | cd helm-charts 151 | 152 | git config user.name "Romeo Dumitrescu" 153 | git config user.email "5931333+winromulus@users.noreply.github.com" 154 | git add . 155 | git status 156 | git commit -m "Added sftp-${{env.version}}.tgz" 157 | git push 158 | 159 | - name: github - create release 160 | uses: softprops/action-gh-release@v1 161 | with: 162 | tag_name: v${{env.version}} 163 | body: The release process is automated. 164 | token: ${{ secrets.ES_GITHUB_PAT }} 165 | 166 | -------------------------------------------------------------------------------- /src/ES.SFTP/Security/UserManagementService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Text; 3 | using ES.SFTP.Interop; 4 | using ES.SFTP.Messages.Configuration; 5 | using ES.SFTP.Messages.Events; 6 | using MediatR; 7 | 8 | namespace ES.SFTP.Security; 9 | 10 | public class UserManagementService : IHostedService, INotificationHandler 11 | { 12 | private const string HomeBasePath = "/home"; 13 | private const string SftpUserInventoryGroup = "sftp-user-inventory"; 14 | private readonly ILogger _logger; 15 | private readonly IMediator _mediator; 16 | 17 | public UserManagementService(ILogger logger, IMediator mediator) 18 | { 19 | _logger = logger; 20 | _mediator = mediator; 21 | } 22 | 23 | 24 | [SuppressMessage("ReSharper", "MethodSupportsCancellation")] 25 | public async Task StartAsync(CancellationToken cancellationToken) 26 | { 27 | _logger.LogDebug("Starting"); 28 | 29 | 30 | _logger.LogDebug("Ensuring '{home}' directory exists and has correct permissions", HomeBasePath); 31 | Directory.CreateDirectory(HomeBasePath); 32 | await ProcessUtil.QuickRun("chown", $"root:root \"{HomeBasePath}\""); 33 | 34 | _logger.LogDebug("Ensuring group '{group}' exists", SftpUserInventoryGroup); 35 | if (!await GroupUtil.GroupExists(SftpUserInventoryGroup)) 36 | { 37 | _logger.LogInformation("Creating group '{group}'", SftpUserInventoryGroup); 38 | await GroupUtil.GroupCreate(SftpUserInventoryGroup, true); 39 | } 40 | 41 | await SyncUsersAndGroups(); 42 | _logger.LogInformation("Started"); 43 | } 44 | 45 | [SuppressMessage("ReSharper", "MethodSupportsCancellation")] 46 | public Task StopAsync(CancellationToken cancellationToken) 47 | { 48 | _logger.LogDebug("Stopping"); 49 | _logger.LogInformation("Stopped"); 50 | return Task.CompletedTask; 51 | } 52 | 53 | public async Task Handle(ConfigurationChanged notification, CancellationToken cancellationToken) 54 | { 55 | await SyncUsersAndGroups(); 56 | } 57 | 58 | private async Task SyncUsersAndGroups() 59 | { 60 | var config = await _mediator.Send(new SftpConfigurationRequest()); 61 | 62 | _logger.LogInformation("Synchronizing users and groups"); 63 | 64 | 65 | //Remove users that do not exist in config anymore 66 | var existingUsers = await GroupUtil.GroupListUsers(SftpUserInventoryGroup); 67 | var toRemove = existingUsers.Where(s => !config.Users.Select(t => t.Username).Contains(s)).ToList(); 68 | foreach (var user in toRemove) 69 | { 70 | _logger.LogDebug("Removing user '{user}'", user, SftpUserInventoryGroup); 71 | await UserUtil.UserDelete(user, false); 72 | } 73 | 74 | //Create groups as specified by the GID value for each user 75 | foreach (var user in config.Users) 76 | { 77 | if (user.GID.HasValue) 78 | { 79 | _logger.LogInformation("Processing GID for user '{user}'", user.Username); 80 | 81 | var virtualGroup = $"sftp-gid-{user.GID.Value}"; 82 | if (!await GroupUtil.GroupExists(virtualGroup)) 83 | { 84 | _logger.LogDebug("Creating group '{group}' with GID '{gid}'", virtualGroup, user.GID.Value); 85 | await GroupUtil.GroupCreate(virtualGroup, true, user.GID.Value); 86 | } 87 | } 88 | } 89 | 90 | foreach (var user in config.Users) 91 | { 92 | _logger.LogInformation("Processing user '{user}'", user.Username); 93 | 94 | if (!await UserUtil.UserExists(user.Username)) 95 | { 96 | _logger.LogDebug("Creating user '{user}'", user.Username); 97 | await UserUtil.UserCreate(user.Username, true, user.GID); 98 | _logger.LogDebug("Adding user '{user}' to '{group}'", user.Username, SftpUserInventoryGroup); 99 | await GroupUtil.GroupAddUser(SftpUserInventoryGroup, user.Username); 100 | } 101 | 102 | 103 | _logger.LogDebug("Updating the password for user '{user}'", user.Username); 104 | await UserUtil.UserSetPassword(user.Username, user.Password, user.PasswordIsEncrypted); 105 | 106 | if (user.UID.HasValue && await UserUtil.UserGetId(user.Username) != user.UID.Value) 107 | { 108 | _logger.LogDebug("Updating the UID for user '{user}'", user.Username); 109 | await UserUtil.UserSetId(user.Username, user.UID.Value); 110 | } 111 | 112 | var homeDir = Directory.CreateDirectory(Path.Combine(HomeBasePath, user.Username)); 113 | await ProcessUtil.QuickRun("chown", $"root:root {homeDir.FullName}"); 114 | await ProcessUtil.QuickRun("chmod", $"711 {homeDir.FullName}"); 115 | 116 | var sshDir = Directory.CreateDirectory(Path.Combine(homeDir.FullName, ".ssh")); 117 | var sshKeysDir = Directory.CreateDirectory(Path.Combine(sshDir.FullName, "keys")); 118 | var sshAuthKeysPath = Path.Combine(sshDir.FullName, "authorized_keys"); 119 | if (File.Exists(sshAuthKeysPath)) File.Delete(sshAuthKeysPath); 120 | var authKeysBuilder = new StringBuilder(); 121 | foreach (var file in Directory.GetFiles(sshKeysDir.FullName)) 122 | { 123 | _logger.LogDebug("Adding public key '{file}' for user '{user}'", file, user.Username); 124 | authKeysBuilder.AppendLine(await File.ReadAllTextAsync(file)); 125 | } 126 | 127 | foreach (var publicKey in user.PublicKeys) 128 | { 129 | _logger.LogDebug("Adding public key from config for user '{user}'", user.Username); 130 | authKeysBuilder.AppendLine(publicKey); 131 | } 132 | 133 | await File.WriteAllTextAsync(sshAuthKeysPath, authKeysBuilder.ToString()); 134 | await ProcessUtil.QuickRun("chown", $"{user.Username} {sshAuthKeysPath}"); 135 | await ProcessUtil.QuickRun("chmod", $"400 {sshAuthKeysPath}"); 136 | } 137 | 138 | 139 | foreach (var groupDefinition in config.Groups) 140 | { 141 | _logger.LogInformation("Processing group '{group}'", groupDefinition.Name); 142 | 143 | var groupUsers = groupDefinition.Users ?? new List(); 144 | if (!await GroupUtil.GroupExists(groupDefinition.Name)) 145 | { 146 | _logger.LogDebug("Creating group '{group}' with GID '{gid}'", groupDefinition.Name, 147 | groupDefinition.GID); 148 | await GroupUtil.GroupCreate(groupDefinition.Name, true, groupDefinition.GID); 149 | } 150 | 151 | if (groupDefinition.GID.HasValue) 152 | { 153 | var currentId = await GroupUtil.GroupGetId(groupDefinition.Name); 154 | if (currentId != groupDefinition.GID.Value) 155 | { 156 | _logger.LogDebug("Updating group '{group}' with GID '{gid}'", groupDefinition.Name, 157 | groupDefinition.GID); 158 | await GroupUtil.GroupSetId(groupDefinition.Name, groupDefinition.GID.Value); 159 | } 160 | } 161 | 162 | var members = await GroupUtil.GroupListUsers(groupDefinition.Name); 163 | var toAdd = groupUsers.Where(s => !members.Contains(s)).ToList(); 164 | foreach (var user in toAdd) 165 | { 166 | if (!await UserUtil.UserExists(user)) continue; 167 | _logger.LogDebug("Adding user '{user}' to '{group}'", user, groupDefinition.Name); 168 | await GroupUtil.GroupAddUser(groupDefinition.Name, user); 169 | } 170 | 171 | members = await GroupUtil.GroupListUsers(groupDefinition.Name); 172 | var usersToRemove = members.Where(s => !groupUsers.Contains(s)).ToList(); 173 | foreach (var user in usersToRemove) 174 | { 175 | _logger.LogDebug("Removing user '{user}'", user, groupDefinition.Name); 176 | await GroupUtil.GroupRemoveUser(groupDefinition.Name, user); 177 | } 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /src/ES.SFTP/SSH/SSHService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using ES.SFTP.Configuration.Elements; 3 | using ES.SFTP.Interop; 4 | using ES.SFTP.Messages.Configuration; 5 | using ES.SFTP.Messages.Events; 6 | using ES.SFTP.SSH.Configuration; 7 | using MediatR; 8 | 9 | namespace ES.SFTP.SSH; 10 | 11 | public class SSHService : IHostedService, INotificationHandler 12 | { 13 | private const string SshDirPath = "/etc/ssh"; 14 | private static readonly string KeysImportDirPath = Path.Combine(SshDirPath, "keys"); 15 | private static readonly string ConfigFilePath = Path.Combine(SshDirPath, "sshd_config"); 16 | private readonly ILogger _logger; 17 | private readonly IMediator _mediator; 18 | private bool _loggingIgnoreNoIdentificationString; 19 | private Process _serverProcess; 20 | private Action _serviceProcessExitAction; 21 | 22 | 23 | public SSHService(ILogger logger, IMediator mediator) 24 | { 25 | _logger = logger; 26 | _mediator = mediator; 27 | } 28 | 29 | public async Task StartAsync(CancellationToken cancellationToken) 30 | { 31 | _logger.LogDebug("Starting"); 32 | await RestartService(true); 33 | _logger.LogInformation("Started"); 34 | } 35 | 36 | public async Task StopAsync(CancellationToken cancellationToken) 37 | { 38 | _logger.LogDebug("Stopping"); 39 | await StopOpenSSH(); 40 | _logger.LogInformation("Stopped"); 41 | } 42 | 43 | public async Task Handle(ConfigurationChanged notification, CancellationToken cancellationToken) 44 | { 45 | await RestartService(); 46 | } 47 | 48 | private async Task RestartService(bool forceStop = false) 49 | { 50 | await StopOpenSSH(forceStop); 51 | await UpdateHostKeyFiles(); 52 | await UpdateConfiguration(); 53 | await StartOpenSSH(); 54 | } 55 | 56 | 57 | private async Task UpdateConfiguration() 58 | { 59 | var sftpConfig = await _mediator.Send(new SftpConfigurationRequest()); 60 | _loggingIgnoreNoIdentificationString = sftpConfig.Global.Logging.IgnoreNoIdentificationString; 61 | 62 | var sshdConfig = new SSHConfiguration 63 | { 64 | Ciphers = sftpConfig.Global.Ciphers, 65 | HostKeyAlgorithms = sftpConfig.Global.HostKeyAlgorithms, 66 | KexAlgorithms = sftpConfig.Global.KexAlgorithms, 67 | MACs = sftpConfig.Global.MACs, 68 | PKIandPassword = sftpConfig.Global.PKIandPassword 69 | }; 70 | 71 | var exceptionalUsers = sftpConfig.Users.Where(s => s.Chroot != null).ToList(); 72 | 73 | var standardDeclarations = new[] 74 | { 75 | "X11Forwarding no", 76 | "AllowTcpForwarding no" 77 | }; 78 | 79 | sshdConfig.AllowUsers.AddRange(sftpConfig.Users.Select(s => 80 | s.AllowedHosts.Any() 81 | ? $"{s.Username}@{string.Join(",", s.AllowedHosts)}" 82 | : s.Username) 83 | ); 84 | 85 | sshdConfig.MatchBlocks.AddRange(exceptionalUsers.Select(s => new MatchBlock 86 | { 87 | Criteria = MatchBlock.MatchCriteria.User, 88 | Match = {s.Username}, 89 | Declarations = new List(standardDeclarations) 90 | { 91 | $"ChrootDirectory {s.Chroot.Directory}", 92 | !string.IsNullOrWhiteSpace(s.Chroot.StartPath) 93 | ? $"ForceCommand internal-sftp -d {s.Chroot.StartPath}" 94 | : "ForceCommand internal-sftp" 95 | } 96 | })); 97 | 98 | sshdConfig.MatchBlocks.Add(new MatchBlock 99 | { 100 | Criteria = MatchBlock.MatchCriteria.User, 101 | Match = {"*"}, 102 | //Except = exceptionalUsers.Select(s => s.Username).ToList(), 103 | Declarations = new List(standardDeclarations) 104 | { 105 | $"ChrootDirectory {sftpConfig.Global.Chroot.Directory}", 106 | !string.IsNullOrWhiteSpace(sftpConfig.Global.Chroot.StartPath) 107 | ? $"ForceCommand internal-sftp -d {sftpConfig.Global.Chroot.StartPath}" 108 | : "ForceCommand internal-sftp" 109 | } 110 | }); 111 | 112 | var resultingConfig = sshdConfig.ToString(); 113 | await File.WriteAllTextAsync(ConfigFilePath, resultingConfig); 114 | } 115 | 116 | private async Task UpdateHostKeyFiles() 117 | { 118 | var config = await _mediator.Send(new SftpConfigurationRequest()); 119 | _logger.LogDebug("Updating host key files"); 120 | Directory.CreateDirectory(KeysImportDirPath); 121 | 122 | var hostKeys = new[] 123 | { 124 | new 125 | { 126 | Type = nameof(HostKeysDefinition.Ed25519), 127 | KeygenArgs = "-t ed25519 -f {0} -N \"\"", 128 | File = "ssh_host_ed25519_key" 129 | }, 130 | new 131 | { 132 | Type = nameof(HostKeysDefinition.Rsa), 133 | KeygenArgs = "-t rsa -b 4096 -f {0} -N \"\"", 134 | File = "ssh_host_rsa_key" 135 | } 136 | }; 137 | 138 | foreach (var hostKeyType in hostKeys) 139 | { 140 | var filePath = Path.Combine(KeysImportDirPath, hostKeyType.File); 141 | if (File.Exists(filePath)) continue; 142 | var configValue = (string) config.Global.HostKeys.GetType().GetProperty(hostKeyType.Type) 143 | ?.GetValue(config.Global.HostKeys, null); 144 | 145 | if (!string.IsNullOrWhiteSpace(configValue)) 146 | { 147 | _logger.LogDebug("Writing host key file '{file}' from config", filePath); 148 | await File.WriteAllTextAsync(filePath, configValue); 149 | } 150 | else 151 | { 152 | _logger.LogDebug("Generating host key file '{file}'", filePath); 153 | var keygenArgs = string.Format(hostKeyType.KeygenArgs, filePath); 154 | await ProcessUtil.QuickRun("ssh-keygen", keygenArgs); 155 | } 156 | } 157 | 158 | foreach (var file in Directory.GetFiles(KeysImportDirPath)) 159 | { 160 | var targetFile = Path.Combine(SshDirPath, Path.GetFileName(file)); 161 | _logger.LogDebug("Copying '{sourceFile}' to '{targetFile}'", file, targetFile); 162 | File.Copy(file, targetFile, true); 163 | await ProcessUtil.QuickRun("chown", $"root:root \"{targetFile}\""); 164 | await ProcessUtil.QuickRun("chmod", $"700 \"{targetFile}\""); 165 | } 166 | } 167 | 168 | 169 | private async Task StartOpenSSH() 170 | { 171 | _logger.LogInformation("Starting 'sshd' process"); 172 | _serviceProcessExitAction = () => 173 | { 174 | _logger.LogWarning("'sshd' process has stopped. Restarting process."); 175 | RestartService().Wait(); 176 | }; 177 | 178 | void ListenForExit() 179 | { 180 | //Use this approach since the Exited event does not trigger on process crash 181 | Task.Run(() => 182 | { 183 | _serverProcess.WaitForExit(); 184 | _serviceProcessExitAction?.Invoke(); 185 | }); 186 | } 187 | 188 | _serverProcess = new Process 189 | { 190 | StartInfo = 191 | { 192 | FileName = "/usr/sbin/sshd", 193 | Arguments = "-D -e", 194 | UseShellExecute = false, 195 | RedirectStandardOutput = true, 196 | RedirectStandardError = true, 197 | CreateNoWindow = true 198 | } 199 | }; 200 | _serverProcess.OutputDataReceived -= OnSSHOutput; 201 | _serverProcess.ErrorDataReceived -= OnSSHOutput; 202 | _serverProcess.OutputDataReceived += OnSSHOutput; 203 | _serverProcess.ErrorDataReceived += OnSSHOutput; 204 | _serverProcess.Start(); 205 | ListenForExit(); 206 | _serverProcess.BeginOutputReadLine(); 207 | _serverProcess.BeginErrorReadLine(); 208 | await _mediator.Publish(new ServerStartupEvent()); 209 | } 210 | 211 | private void OnSSHOutput(object sender, DataReceivedEventArgs e) 212 | { 213 | if (string.IsNullOrWhiteSpace(e.Data)) return; 214 | if (_loggingIgnoreNoIdentificationString && 215 | e.Data.Trim().StartsWith("Did not receive identification string from")) return; 216 | _logger.LogTrace($"sshd - {e.Data}"); 217 | } 218 | 219 | private async Task StopOpenSSH(bool force = false) 220 | { 221 | if (_serverProcess != null) 222 | { 223 | _logger.LogDebug("Stopping 'sshd' process"); 224 | _serviceProcessExitAction = null; 225 | _serverProcess.Kill(true); 226 | _serverProcess.OutputDataReceived -= OnSSHOutput; 227 | _serverProcess.ErrorDataReceived -= OnSSHOutput; 228 | _logger.LogInformation("Stopped 'sshd' process"); 229 | _serverProcess.Dispose(); 230 | _serverProcess = null; 231 | } 232 | 233 | if (force) 234 | { 235 | var arguments = Debugger.IsAttached ? "-q sshd" : "-q -w sshd"; 236 | var command = await ProcessUtil.QuickRun("killall", arguments, false); 237 | if (command.ExitCode != 0 && command.ExitCode != 1 && !string.IsNullOrWhiteSpace(command.Output)) 238 | throw new Exception( 239 | $"Could not stop existing sshd processes.{Environment.NewLine}{command.Output}"); 240 | } 241 | } 242 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SFTP ([SSH File Transfer Protocol](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol)) server using [OpenSSH](https://en.wikipedia.org/wiki/OpenSSH) 2 | This project provides a Docker image for hosting a SFTP server. Included are `Docker` (`docker-cli` and `docker-compose`) and `Kubernetes` (`kubectl` and `helm`) deployment scripts 3 | 4 | [![Pipeline](https://github.com/emberstack/docker-sftp/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/emberstack/docker-sftp/actions/workflows/pipeline.yaml) 5 | [![Release](https://img.shields.io/github/release/emberstack/docker-sftp.svg?style=flat-square)](https://github.com/emberstack/docker-sftp/releases/latest) 6 | [![Docker Image](https://img.shields.io/docker/image-size/emberstack/sftp?style=flat-square)](https://hub.docker.com/r/emberstack/sftp) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/emberstack/sftp?style=flat-square)](https://hub.docker.com/r/emberstack/sftp) 8 | [![license](https://img.shields.io/github/license/emberstack/docker-sftp.svg?style=flat-square)](LICENSE) 9 | 10 | > Supports architectures: `amd64`, `arm` and `arm64` 11 | 12 | ### Support 13 | If you need help or found a bug, please feel free to open an issue on the [emberstack/docker-sftp](https://github.com/emberstack/docker-sftp) GitHub project. 14 | 15 | ## Usage 16 | 17 | The SFTP server can be easily deployed to any platform that can host containers based on Docker. 18 | Below are deployment methods for: 19 | - Docker CLI 20 | - Docker-Compose 21 | - Kubernetes using Helm (recommended for Kubernetes) 22 | 23 | Process: 24 | 1) Create server configuration 25 | 2) Mount volumes as needed 26 | 3) Set host file for consistent server fingerprint 27 | 28 | ### Configuration 29 | 30 | The SFTP server uses a `json` based configuration file for default server options and to define users. This file has to be mounted on `/app/config/sftp.json` inside the container. 31 | Environment variable based configuration is not supported (see the `Advanced Configuration` section below for the reasons). 32 | 33 | Below is the simplest configuration file for the SFTP server: 34 | 35 | ```json 36 | { 37 | "Global": { 38 | "Chroot": { 39 | "Directory": "%h", 40 | "StartPath": "sftp" 41 | }, 42 | "Directories": ["sftp"] 43 | }, 44 | "Users": [ 45 | { 46 | "Username": "demo", 47 | "Password": "demo" 48 | } 49 | ] 50 | } 51 | ``` 52 | This configuration creates a user `demo` with the password `demo`. 53 | A directory "sftp" is created for each user in the own home and is accessible for read/write. 54 | The user is `chrooted` to the `/home/demo` directory. Upon connect, the start directory is `sftp`. 55 | 56 | You can add additional users, default directories or customize start directories per user. You can also define the `UID` and `GID` for each user. See the `Advanced Configuration` section below for all configuration options. 57 | 58 | 59 | ### Deployment using Docker CLI 60 | 61 | > Simple Docker CLI run 62 | 63 | ```shellsession 64 | $ docker run -p 22:22 -d emberstack/sftp --name sftp 65 | ``` 66 | This will start a SFTP in the container `sftp` with the default configuration. You can connect to it and login with the `user: demo` and `password: demo`. 67 | 68 | > Provide your configuration 69 | 70 | ```shellsession 71 | $ docker run -p 22:22 -d emberstack/sftp --name sftp -v /host/sftp.json:/app/config/sftp.json:ro 72 | ``` 73 | This will override the default (`/app/config/sftp.json`) configuration with the one from the host `/host/sftp.json`. 74 | 75 | > Mount a directory from the host for the user 'demo' 76 | 77 | ```shellsession 78 | $ docker run -p 22:22 -d emberstack/sftp --name sftp -v /host/sftp.json:/app/config/sftp.json:ro -v /host/demo:/home/demo/sftp 79 | ``` 80 | This will mount the `demo` directory from the host on the `sftp` directory for the "demo" user. 81 | 82 | 83 | ### Deployment using Docker Compose 84 | 85 | > Simple docker-compose configuration 86 | 87 | Create a docker-compose configuration file: 88 | ```yaml 89 | version: '3' 90 | services: 91 | sftp: 92 | image: "emberstack/sftp" 93 | ports: 94 | - "22:22" 95 | volumes: 96 | - ../config-samples/sample.sftp.json:/app/config/sftp.json:ro 97 | ``` 98 | And run it using docker-compose 99 | ```shellsession 100 | $ docker-compose -p sftp -f docker-compose.yaml up -d 101 | ``` 102 | 103 | The above configuration is available in the `deploy\docker-compose` folder in this repository. You can use it to start customizing the deployment for your environment. 104 | 105 | 106 | 107 | ### Deployment to Kubernetes using Helm 108 | 109 | Use Helm to install the latest released chart: 110 | ```shellsession 111 | $ helm repo add emberstack https://emberstack.github.io/helm-charts 112 | $ helm repo update 113 | $ helm upgrade --install sftp emberstack/sftp 114 | ``` 115 | 116 | You can customize the values of the helm deployment by using the following Values: 117 | 118 | | Parameter | Description | Default | 119 | | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------- | 120 | | `nameOverride` | Overrides release name | `""` | 121 | | `fullnameOverride` | Overrides release fullname | `""` | 122 | | `image.repository` | Container image repository | `emberstack/sftp` | 123 | | `image.tag` | Container image tag | `latest` | 124 | | `image.pullPolicy` | Container image pull policy | `Always` if `image.tag` is `latest`, else `IfNotPresent`| 125 | | `storage.volumes` | Defines additional volumes for the pod | `{}` | 126 | | `storage.volumeMounts` | Defines additional volumes mounts for the sftp container | `{}` | 127 | | `configuration` | Allows the in-line override of the configuration values | `null` | 128 | | `configuration.Global.Chroot.Directory` | Global chroot directory for the `sftp` user group. Can be overriden per-user | `"%h"` | 129 | | `configuration.Global.Chroot.StartPath` | Start path for the `sftp` user group. Can be overriden per-user | `"sftp"` | 130 | | `configuration.Global.Directories` | Directories that get created for all `sftp` users. Can be appended per user | `["sftp"]` | 131 | | `configuration.Global.HostKeys.Ed25519` | Set the server's ED25519 private key | `""` | 132 | | `configuration.Global.HostKeys.Rsa` | Set the server's RSA private key | `""` | 133 | | `configuration.Users` | Array of users and their properties | Contains `demo` user by default | 134 | | `configuration.Users[].Username` | Set the user's username | N/A | 135 | | `configuration.Users[].Password` | Set the user's password. If empty or `null`, password authentication is disabled | N/A | 136 | | `configuration.Users[].PasswordIsEncrypted` | `true` or `false`. Indicates if the password value is already encrypted | `false` | 137 | | `configuration.Users[].AllowedHosts` | Set the user's allowed hosts. If empty, any host is allowed | `[]` | 138 | | `configuration.Users[].PublicKeys` | Set the user's public keys | `[]` | 139 | | `configuration.Users[].UID` | Sets the user's UID. | `null` | 140 | | `configuration.Users[].GID` | Sets the user's GID. A group is created for this value and the user is included | `null` | 141 | | `configuration.Users[].Chroot` | If set, will override global `Chroot` settings for this user. | `null` | 142 | | `configuration.Users[].Directories` | Array of additional directories created for this user | `null` | 143 | | `initContainers` | Additional initContainers for the pod | `{}` | 144 | | `resources` | Resource limits | `{}` | 145 | | `nodeSelector` | Node labels for pod assignment | `{}` | 146 | | `tolerations` | Toleration labels for pod assignment | `[]` | 147 | | `affinity` | Node affinity for pod assignment | `{}` | 148 | 149 | > Find us on [Helm Hub](https://hub.helm.sh/charts/emberstack) 150 | 151 | 152 | ## Advanced Configuration 153 | 154 | TODO: This section is under development due to the number of configuration options being added. Please open an issue on the [emberstack/docker-sftp](https://github.com/emberstack/docker-sftp) project if you need help. 155 | 156 | --------------------------------------------------------------------------------