[
  {
    "path": ".dockerignore",
    "content": "**/.classpath\r\n**/.dockerignore\r\n**/.env\r\n**/.git\r\n**/.gitignore\r\n**/.project\r\n**/.settings\r\n**/.toolstarget\r\n**/.vs\r\n**/.vscode\r\n**/*.*proj.user\r\n**/*.dbmdl\r\n**/*.jfm\r\n**/azds.yaml\r\n**/bin\r\n**/charts\r\n**/docker-compose*\r\n**/Dockerfile*\r\n**/node_modules\r\n**/npm-debug.log\r\n**/obj\r\n**/secrets.dev.yaml\r\n**/values.dev.yaml\r\nLICENSE\r\nREADME.md"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: nuget\n  directory: \"/\"\n  schedule:\n    interval: daily\n  open-pull-requests-limit: 10\n"
  },
  {
    "path": ".github/stale.yml",
    "content": "# Configuration for probot-stale - https://github.com/probot/stale\n\n# Number of days of inactivity before an Issue or Pull Request becomes stale\ndaysUntilStale: 7\n\n# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.\n# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.\ndaysUntilClose: 7\n\n# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)\nonlyLabels: []\n\n# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable\nexemptLabels:\n  - pinned\n  - security\n  - \"[Status] Maybe Later\"\n\n# Set to true to ignore issues in a project (defaults to false)\nexemptProjects: false\n\n# Set to true to ignore issues in a milestone (defaults to false)\nexemptMilestones: false\n\n# Set to true to ignore issues with an assignee (defaults to false)\nexemptAssignees: false\n\n# Label to use when marking as stale\nstaleLabel: stale\n\n# Comment to post when marking as stale. Set to `false` to disable\nmarkComment: >\n  Automatically marked as stale due to no recent activity. \n  It will be closed if no further activity occurs. Thank you for your contributions.\n\n# Comment to post when removing the stale label.\nunmarkComment: >\n  Removed stale label.\n\n# Comment to post when closing a stale Issue or Pull Request.\ncloseComment: >\n  Automatically closed stale item.\n\n# Limit the number of actions per hour, from 1-30. Default is 30\nlimitPerRun: 30\n\n# Limit to only `issues` or `pulls`\n# only: issues\n\n# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':\n# pulls:\n#   daysUntilStale: 30\n#   markComment: >\n#     This pull request has been automatically marked as stale because it has not had\n#     recent activity. It will be closed if no further activity occurs. Thank you\n#     for your contributions.\n\n# issues:\n#   exemptLabels:\n#     - confirmed"
  },
  {
    "path": ".github/workflows/pipeline.yaml",
    "content": "name: Pipeline\r\n\r\non:\r\n  push:\r\n    paths:\r\n      - \"src/**\"\r\n      - \".github/workflows/**\"\r\n  pull_request:\r\n    paths:\r\n      - \"src/**\"\r\n      - \".github/workflows/**\"\r\n\r\nenv:\r\n  version: 5.1.${{github.run_number}}\r\n  imageRepository: \"emberstack/sftp\"\r\n  DOCKER_CLI_EXPERIMENTAL: \"enabled\"\r\n\r\n\r\njobs:\r\n  ci:\r\n    name: CI\r\n    runs-on: ubuntu-latest\r\n    steps:\r\n\r\n      - name: tools - helm - install\r\n        uses: azure/setup-helm@v1\r\n\r\n      - name: checkout\r\n        uses: actions/checkout@v2\r\n\r\n      - name: artifacts - prepare directories\r\n        run: |\r\n          mkdir -p .artifacts/helm\r\n\r\n      - name: helm - import README\r\n        run: cp README.md src/helm/sftp/README.md\r\n\r\n      - name: helm - package chart\r\n        run: helm package --destination .artifacts/helm --version ${{env.version}} --app-version ${{env.version}} src/helm/sftp\r\n\r\n      - name: \"artifacts - upload - helm chart\"\r\n        uses: actions/upload-artifact@v2\r\n        with:\r\n          name: helm\r\n          path: .artifacts/helm\r\n\r\n      - name: tools - docker - login\r\n        if: github.event_name == 'push'\r\n        uses: docker/login-action@v1\r\n        with:\r\n          username: ${{ secrets.ES_DOCKERHUB_USERNAME }}\r\n          password: ${{ secrets.ES_DOCKERHUB_PAT }}\r\n\r\n      - name: \"docker - buildx prepare\"\r\n        run: |\r\n          docker run --rm --privileged multiarch/qemu-user-static --reset -p yes\r\n          docker buildx create --name builder --driver docker-container --use\r\n          docker buildx inspect --bootstrap\r\n\r\n\r\n\r\n      - name: \"docker - build PR\"\r\n        if: github.event_name == 'pull_request'\r\n        run: |\r\n          docker buildx build --platform linux/amd64 -t ${{env.imageRepository}}:build-${{env.version}}-amd64   -f src/ES.SFTP/Dockerfile src/\r\n          docker buildx build --platform linux/arm   -t ${{env.imageRepository}}:build-${{env.version}}-arm32v7 -f src/ES.SFTP/Dockerfile src/\r\n          docker buildx build --platform linux/arm64 -t ${{env.imageRepository}}:build-${{env.version}}-arm64v8 -f src/ES.SFTP/Dockerfile src/\r\n\r\n\r\n\r\n      - name: \"docker - build and publish - amd64\"\r\n        if: github.event_name == 'push'\r\n        run: |\r\n          docker buildx build --push --platform linux/amd64 --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-amd64   -f src/ES.SFTP/Dockerfile src/\r\n\r\n      - name: \"docker - build and publish - arm32v7\"\r\n        if: github.event_name == 'push'\r\n        run: |\r\n          docker buildx build --push --platform linux/arm   --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-arm32v7 -f src/ES.SFTP/Dockerfile src/\r\n\r\n      - name: \"docker - build and publish - arm64v8\"\r\n        if: github.event_name == 'push'\r\n        run: |\r\n          docker buildx build --push --platform linux/arm64 --provenance=false -t ${{env.imageRepository}}:build-${{env.version}}-arm64v8 -f src/ES.SFTP/Dockerfile src/\r\n          \r\n\r\n      - name: \"docker - create manifest and publish\"\r\n        if: github.event_name == 'push'\r\n        run: |\r\n          docker pull --platform linux/amd64 ${{env.imageRepository}}:build-${{env.version}}-amd64\r\n          docker pull --platform linux/arm/v7 ${{env.imageRepository}}:build-${{env.version}}-arm32v7\r\n          docker pull --platform linux/arm64 ${{env.imageRepository}}:build-${{env.version}}-arm64v8\r\n          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\r\n          docker manifest inspect ${{env.imageRepository}}:build-${{env.version}}\r\n          docker manifest push    ${{env.imageRepository}}:build-${{env.version}}\r\n\r\n  cd:\r\n    name: CD\r\n    needs: ci\r\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\r\n    runs-on: ubuntu-latest\r\n    steps:\r\n      - name: tools - helm - install\r\n        uses: azure/setup-helm@v1\r\n\r\n      - name: tools - docker - login\r\n        uses: docker/login-action@v1\r\n        with:\r\n          username: ${{ secrets.ES_DOCKERHUB_USERNAME }}\r\n          password: ${{ secrets.ES_DOCKERHUB_PAT }}\r\n\r\n      - name: artifacts - download - helm chart\r\n        uses: actions/download-artifact@v2\r\n        with:\r\n          name: helm\r\n          path: .artifacts/helm\r\n\r\n      - name: \"docker - create manifest and publish\"\r\n        run: |\r\n          docker pull ${{env.imageRepository}}:build-${{env.version}}-amd64\r\n          docker pull ${{env.imageRepository}}:build-${{env.version}}-arm32v7\r\n          docker pull ${{env.imageRepository}}:build-${{env.version}}-arm64v8\r\n          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\r\n          docker manifest create  ${{env.imageRepository}}:latest               ${{env.imageRepository}}:build-${{env.version}}-amd64 ${{env.imageRepository}}:build-${{env.version}}-arm32v7 ${{env.imageRepository}}:build-${{env.version}}-arm64v8\r\n          docker manifest push    ${{env.imageRepository}}:${{env.version}}\r\n          docker manifest push    ${{env.imageRepository}}:latest\r\n          docker manifest push    ${{env.imageRepository}}:${{env.version}}\r\n          docker manifest push    ${{env.imageRepository}}:latest\r\n          docker tag  ${{env.imageRepository}}:build-${{env.version}}-amd64     ${{env.imageRepository}}:${{env.version}}-amd64\r\n          docker tag  ${{env.imageRepository}}:build-${{env.version}}-arm32v7   ${{env.imageRepository}}:${{env.version}}-arm32v7\r\n          docker tag  ${{env.imageRepository}}:build-${{env.version}}-arm64v8   ${{env.imageRepository}}:${{env.version}}-arm64v8\r\n          docker push ${{env.imageRepository}}:${{env.version}}-amd64\r\n          docker push ${{env.imageRepository}}:${{env.version}}-arm32v7\r\n          docker push ${{env.imageRepository}}:${{env.version}}-arm64v8\r\n\r\n      - name: github - checkout - helm-charts\r\n        uses: actions/checkout@v2\r\n        with:\r\n          repository: emberstack/helm-charts\r\n          token:  ${{ secrets.ES_GITHUB_PAT }}\r\n          path: helm-charts\r\n          ref: main\r\n\r\n\r\n      - name: github - publish - chart\r\n        run: |\r\n          mkdir -p helm-charts/repository/sftp\r\n          cp .artifacts/helm/sftp-${{env.version}}.tgz  helm-charts/repository/sftp\r\n\r\n          cd helm-charts\r\n\r\n          git config user.name \"Romeo Dumitrescu\"\r\n          git config user.email \"5931333+winromulus@users.noreply.github.com\"\r\n          git add .\r\n          git status\r\n          git commit -m \"Added sftp-${{env.version}}.tgz\"\r\n          git push\r\n\r\n      - name: github - create release\r\n        uses: softprops/action-gh-release@v1\r\n        with:\r\n          tag_name: v${{env.version}}\r\n          body: The release process is automated.\r\n          token: ${{ secrets.ES_GITHUB_PAT }}\r\n            \r\n"
  },
  {
    "path": ".gitignore",
    "content": "## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUNIT\n*.VisualState.xml\nTestResult.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n# **/Properties/launchSettings.json\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_i.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# JustCode is a .NET coding add-in\n.JustCode\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk \n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# JetBrains Rider\n.idea/\n*.sln.iml\n\n# CodeRush\n.cr/\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output \nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder \n.mfractor/\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 emberstack\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# SFTP ([SSH File Transfer Protocol](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol)) server using [OpenSSH](https://en.wikipedia.org/wiki/OpenSSH)\nThis 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\n\n[![Pipeline](https://github.com/emberstack/docker-sftp/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/emberstack/docker-sftp/actions/workflows/pipeline.yaml)\n[![Release](https://img.shields.io/github/release/emberstack/docker-sftp.svg?style=flat-square)](https://github.com/emberstack/docker-sftp/releases/latest)\n[![Docker Image](https://img.shields.io/docker/image-size/emberstack/sftp?style=flat-square)](https://hub.docker.com/r/emberstack/sftp)\n[![Docker Pulls](https://img.shields.io/docker/pulls/emberstack/sftp?style=flat-square)](https://hub.docker.com/r/emberstack/sftp)\n[![license](https://img.shields.io/github/license/emberstack/docker-sftp.svg?style=flat-square)](LICENSE)\n\n> Supports architectures: `amd64`, `arm` and `arm64`\n\n### Support\nIf 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.  \n\n## Usage\n\nThe SFTP server can be easily deployed to any platform that can host containers based on Docker.\nBelow are deployment methods for:\n- Docker CLI\n- Docker-Compose\n- Kubernetes using Helm (recommended for Kubernetes)\n\nProcess:\n1) Create server configuration\n2) Mount volumes as needed\n3) Set host file for consistent server fingerprint\n\n### Configuration\n\nThe 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.\nEnvironment variable based configuration is not supported (see the `Advanced Configuration` section below for the reasons).\n\nBelow is the simplest configuration file for the SFTP server:\n\n```json\n{\n    \"Global\": {\n        \"Chroot\": {\n            \"Directory\": \"%h\",\n            \"StartPath\": \"sftp\"\n        },\n        \"Directories\": [\"sftp\"]\n    },\n    \"Users\": [\n        {\n            \"Username\": \"demo\",\n            \"Password\": \"demo\"\n        }\n    ]\n}\n```\nThis configuration creates a user `demo` with the password `demo`. \nA directory \"sftp\" is created for each user in the own home and is accessible for read/write. \nThe user is `chrooted` to the `/home/demo` directory. Upon connect, the start directory is `sftp`.\n\nYou 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.\n\n\n### Deployment using Docker CLI\n\n> Simple Docker CLI run\n\n```shellsession\n$ docker run -p 22:22 -d emberstack/sftp --name sftp\n```\nThis 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`.\n\n> Provide your configuration\n\n```shellsession\n$ docker run -p 22:22 -d emberstack/sftp --name sftp -v /host/sftp.json:/app/config/sftp.json:ro\n```\nThis will override the default (`/app/config/sftp.json`) configuration with the one from the host `/host/sftp.json`.\n\n> Mount a directory from the host for the user 'demo'\n\n```shellsession\n$ 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\n```\nThis will mount the `demo` directory from the host on the `sftp` directory for the \"demo\" user.\n\n\n### Deployment using Docker Compose\n\n> Simple docker-compose configuration\n\nCreate a docker-compose configuration file:\n```yaml\nversion: '3'\nservices:\n  sftp:\n    image: \"emberstack/sftp\"\n    ports:\n      - \"22:22\"\n    volumes:\n      - ../config-samples/sample.sftp.json:/app/config/sftp.json:ro\n```\nAnd run it using docker-compose\n```shellsession\n$ docker-compose -p sftp -f docker-compose.yaml up -d\n```\n\nThe 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.\n\n\n\n### Deployment to Kubernetes using Helm\n\nUse Helm to install the latest released chart:\n```shellsession\n$ helm repo add emberstack https://emberstack.github.io/helm-charts\n$ helm repo update\n$ helm upgrade --install sftp emberstack/sftp\n```\n\nYou can customize the values of the helm deployment by using the following Values:\n\n| Parameter                                                   | Description                                                                      | Default                                                 |\n| ------------------------------------                        | -------------------------------------------------------------------------------- | ------------------------------------------------------- |\n| `nameOverride`                                              | Overrides release name                                                           | `\"\"`                                                    |\n| `fullnameOverride`                                          | Overrides release fullname                                                       | `\"\"`                                                    |\n| `image.repository`                                          | Container image repository                                                       | `emberstack/sftp`                                       |\n| `image.tag`                                                 | Container image tag                                                              | `latest`                                                |\n| `image.pullPolicy`                                          | Container image pull policy                                                      | `Always` if `image.tag` is `latest`, else `IfNotPresent`|\n| `storage.volumes`                                           | Defines additional volumes for the pod                                           | `{}`                                                    |\n| `storage.volumeMounts`                                      | Defines additional volumes mounts for the sftp container                         | `{}`                                                    |\n| `configuration`                                             | Allows the in-line override of the configuration values                          | `null`                                                  |\n| `configuration.Global.Chroot.Directory`                     | Global chroot directory for the `sftp` user group. Can be overriden per-user     | `\"%h\"`                                                  |\n| `configuration.Global.Chroot.StartPath`                     | Start path for the `sftp` user group. Can be overriden per-user                  | `\"sftp\"`                                                |\n| `configuration.Global.Directories`                          | Directories that get created for all `sftp` users. Can be appended per user      | `[\"sftp\"]`                                              |\n| `configuration.Global.HostKeys.Ed25519`                     | Set the server's ED25519 private key                                             | `\"\"`                                                    |\n| `configuration.Global.HostKeys.Rsa`                         | Set the server's RSA private key                                                 | `\"\"`                                                    |\n| `configuration.Users`                                       | Array of users and their properties                                              | Contains `demo` user by default                         |\n| `configuration.Users[].Username`                            | Set the user's username                                                          | N/A                                                     |\n| `configuration.Users[].Password`                            | Set the user's password. If empty or `null`, password authentication is disabled | N/A                                                     |\n| `configuration.Users[].PasswordIsEncrypted`                 | `true` or `false`. Indicates if the password value is already encrypted          | `false`                                                 |\n| `configuration.Users[].AllowedHosts`                        | Set the user's allowed hosts. If empty, any host is allowed                      | `[]`                                                    |\n| `configuration.Users[].PublicKeys`                          | Set the user's public keys                                                       | `[]`                                                    |\n| `configuration.Users[].UID`                                 | Sets the user's UID.                                                             | `null`                                                  |\n| `configuration.Users[].GID`                                 | Sets the user's GID. A group is created for this value and the user is included  | `null`                                                  |\n| `configuration.Users[].Chroot`                              | If set, will override global `Chroot` settings for this user.                    | `null`                                                  |\n| `configuration.Users[].Directories`                         | Array of additional directories created for this user                            | `null`                                                  |\n| `initContainers`                                            | Additional initContainers for the pod                                            | `{}`                                                    |\n| `resources`                                                 | Resource limits                                                                  | `{}`                                                    |\n| `nodeSelector`                                              | Node labels for pod assignment                                                   | `{}`                                                    |\n| `tolerations`                                               | Toleration labels for pod assignment                                             | `[]`                                                    |\n| `affinity`                                                  | Node affinity for pod assignment                                                 | `{}`                                                    |\n\n> Find us on [Helm Hub](https://hub.helm.sh/charts/emberstack)\n\n\n## Advanced Configuration\n\nTODO: 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.\n\n"
  },
  {
    "path": "samples/.ssh/id_demo2_ed25519",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCItsK7CZxhI38h+dvuQOSbUZpIV84n7QAmt7XXONbxLQAAAIgMsBerDLAX\nqwAAAAtzc2gtZWQyNTUxOQAAACCItsK7CZxhI38h+dvuQOSbUZpIV84n7QAmt7XXONbxLQ\nAAAECGtcsqvGH3fXmxHiuFdK+qYJsJrTpHVP6CCEPnMGByDIi2wrsJnGEjfyH52+5A5JtR\nmkhXziftACa3tdc41vEtAAAAAAECAwQF\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "samples/.ssh/id_demo2_ed25519.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIi2wrsJnGEjfyH52+5A5JtRmkhXziftACa3tdc41vEt \n"
  },
  {
    "path": "samples/.ssh/id_demo2_rsa",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\n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\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "samples/.ssh/id_demo2_rsa.pub",
    "content": "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\n"
  },
  {
    "path": "samples/.ssh/id_demo_rsa",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAvvvPy5DQLtQzxI5wYc40xd3ZBWPo20QyLEP9iZBqUXRB5nAjAUzG\nRi/TwhBBo1qNS36HH55OYVq3JaEzAofqw+VQhTcjwhHjyup8hM2R8HEJPbenVkMMo7SxvL\ngIVQN9LJdFMOPGHDZfWhfBc6TzFpwu6JjNlUD6qtC3tOR7udKtK1ve89L58aUHvoHTYVzk\n6tBUPidsAsBnOy8Z+bl5mRKM5mb5chVRMOnhZcqPQcC5bkx6necIfc/o8dlPHUH5yKH64H\ndg6fOrRjZ70ufS/0/9JZT/Jm702TX7rDCYjuIDTZbGIY+JUMVEGgy+4MvX8ZfD6n9Hhp6T\nl5bkjKQBE9aBWOZCoB235eqXnqWcz0O+y0GQzQ5K08KnOO1QzZcZHQZlfZqdUfgup6PFrN\nUZJPsWKDW18usFThGculUsnTstTrFD6XNYTAC2LZMTbPPEQXlYv9rkGfLLF8MioUyLOMwd\nCV50iWzq55BPzYkfJuq+zY9taET3INCYoZeoKu51AAAFiBbI79wWyO/cAAAAB3NzaC1yc2\nEAAAGBAL77z8uQ0C7UM8SOcGHONMXd2QVj6NtEMixD/YmQalF0QeZwIwFMxkYv08IQQaNa\njUt+hx+eTmFatyWhMwKH6sPlUIU3I8IR48rqfITNkfBxCT23p1ZDDKO0sby4CFUDfSyXRT\nDjxhw2X1oXwXOk8xacLuiYzZVA+qrQt7Tke7nSrStb3vPS+fGlB76B02Fc5OrQVD4nbALA\nZzsvGfm5eZkSjOZm+XIVUTDp4WXKj0HAuW5Mep3nCH3P6PHZTx1B+cih+uB3YOnzq0Y2e9\nLn0v9P/SWU/yZu9Nk1+6wwmI7iA02WxiGPiVDFRBoMvuDL1/GXw+p/R4aek5eW5IykARPW\ngVjmQqAdt+Xql56lnM9DvstBkM0OStPCpzjtUM2XGR0GZX2anVH4LqejxazVGST7Fig1tf\nLrBU4RnLpVLJ07LU6xQ+lzWEwAti2TE2zzxEF5WL/a5BnyyxfDIqFMizjMHQledIls6ueQ\nT82JHybqvs2PbWhE9yDQmKGXqCrudQAAAAMBAAEAAAGAeqEz5vEAS+FjsCUJ0jNWvWparF\nRfs1MRqEyr4oXBTrYIjo+YWoBSm8SgAu7vRpWhPkVrPAkpKOfXy6i7GTfurYRz9GXYZweX\nrbZs59UbjTj3hxKCtyfsWL1wls3QQ84utNAY1HCcx4a+KRox1DCpCe6VTDK5ZsnHaqEEJH\nnFXCcDnGCsQwFIDjo6Q8AW22CLeJ72SMaFWyrx3hW7ZxcKFhjMMjESoIdBj9fNK9Aptj2q\nk0E2RmePk0FJwOkZHJ88R8LY4KbrJtztFltiKiMnpy7P5JORwvKG/t6qHp9WM1R1i87WwF\nggdVW16kR0EOaRb10tt3QWAAtyR4VfYks9KdQvaMoRrp1obr3k1mvayU8sr9hTcYTGtnsR\nzd0YK39isYFQ/IdlBPLa2K5mJ9H3Hb7gx4TloZAGoAGm6wg1Qdz5YZUFe8bjyqixR58Ca5\ncpruON7mnGjjuujkGgWuQNONfl4Y6GsgRJ7gOWEkCyDsgV98YyB87pRhL37LrYLlghAAAA\nwQDE4fkq73lLbwWfSLnyRCLSYW4q/mtqvSqIcybDUUg6vAiZKrclcBUTsoVNs0UtyMtffm\nLcPhOTpZTd/z403bg+dHlq+XYuv13tHrMCTQ67v9qeHU+IGzRV7PpnJjLxfY4x/w8489W0\nEjBLwgKUHjm0HNbU/Y+j5gebgrZ6ulK1YqOXs+o9uCRKKmn0DMG5LBmf/XKjGLubd3RI6f\nrWXrYr8PaOJ65oXvBtvRzC/tT/oWs26QWqyrvL1XOHeso9VT0AAADBAPKWL2avKffBk/hT\nooYowZd1mSX0Li8v+L7AKdtnLWYhQFqaDsy2w7llPGjYlybwHMa9nTPiONn8qwKaTpPc5I\n0pqOCbMr5MIAbg2JVYs9IIqN8hCNBY4QaFY9Kjkvivw/RaWVvWSLLf8DlihBSft2dSmGVY\n+ZTT/5BskqWnI2cfMhmU9gxjTl2XIPzIn3K3EChAatn8MFi3L6IX5Aakfr40ci1b4pIkC/\nwlwS/YX/l8C2FkiK/RaAc7IhmLx6GQnQAAAMEAyYsvhaJNrOXiHUtXj03WRA+dYDUFRMXA\nxkwINNoCZzuOzkoEDjSx3zIqPAbvOGKjC/TVPsmzj5ZLCzD4smsSQCi9pdsS8av6iFoqqe\n5iIpcAWBQsgRvVcqIFzGNddAId/osxhzUBgzoX/3d5MkwBugUwQtAAiucy55B+70cVQynA\nurt4/BFpA13+e2QfRGEck4q/WN5DrBjogfNOWFgAVKxY0IJ+GyjADM/BdPHLux3MQAr7Bc\nqW0Fx9sYyqWxG5AAAAEndpbnJvbXVsdXNATWF4aW11cw==\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "samples/.ssh/id_demo_rsa.pub",
    "content": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC++8/LkNAu1DPEjnBhzjTF3dkFY+jbRDIsQ/2JkGpRdEHmcCMBTMZGL9PCEEGjWo1Lfocfnk5hWrcloTMCh+rD5VCFNyPCEePK6nyEzZHwcQk9t6dWQwyjtLG8uAhVA30sl0Uw48YcNl9aF8FzpPMWnC7omM2VQPqq0Le05Hu50q0rW97z0vnxpQe+gdNhXOTq0FQ+J2wCwGc7Lxn5uXmZEozmZvlyFVEw6eFlyo9BwLluTHqd5wh9z+jx2U8dQfnIofrgd2Dp86tGNnvS59L/T/0llP8mbvTZNfusMJiO4gNNlsYhj4lQxUQaDL7gy9fxl8Pqf0eGnpOXluSMpAET1oFY5kKgHbfl6peepZzPQ77LQZDNDkrTwqc47VDNlxkdBmV9mp1R+C6no8Ws1Rkk+xYoNbXy6wVOEZy6VSydOy1OsUPpc1hMALYtkxNs88RBeVi/2uQZ8ssXwyKhTIs4zB0JXnSJbOrnkE/NiR8m6r7Nj21oRPcg0Jihl6gq7nU= winromulus@Maximus\n"
  },
  {
    "path": "samples/hooks/onsessionchange",
    "content": "#!/bin/bash\n\necho \"Session event '$1' for '$2'\""
  },
  {
    "path": "samples/hooks/onstartup",
    "content": "#!/bin/bash\n\necho \"SSH service startup hook completed.\""
  },
  {
    "path": "samples/sample.dev.sftp.json",
    "content": "{\n    \"Global\": {\n        \"Chroot\": {\n            \"Directory\": \"%h\",\n            \"StartPath\": \"sftp\"\n        },\n        \"Directories\": [\"sftp\"],\n        \"HostKeys\": {\n            \"Ed25519\": \"-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\\nQyNTUxOQAAACBvlz4T2Fh9PKKeVhSupzXsBYVt44VJcb1554gRLKS2oAAAAIiJdbTtiXW0\\n7QAAAAtzc2gtZWQyNTUxOQAAACBvlz4T2Fh9PKKeVhSupzXsBYVt44VJcb1554gRLKS2oA\\nAAAEDI/igTE3dx3UC0As1d4kL0BNDaA3MkO9lDyWXqfErITm+XPhPYWH08op5WFK6nNewF\\nhW3jhUlxvXnniBEspLagAAAAAAECAwQF\\n-----END OPENSSH PRIVATE KEY-----\\n\"\n        }\n    },\n    \"Users\": [\n        {\n            \"Username\": \"demo\",\n            \"Password\": \"demo\",\n            \"PublicKeys\": [\n                \"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC++8/LkNAu1DPEjnBhzjTF3dkFY+jbRDIsQ/2JkGpRdEHmcCMBTMZGL9PCEEGjWo1Lfocfnk5hWrcloTMCh+rD5VCFNyPCEePK6nyEzZHwcQk9t6dWQwyjtLG8uAhVA30sl0Uw48YcNl9aF8FzpPMWnC7omM2VQPqq0Le05Hu50q0rW97z0vnxpQe+gdNhXOTq0FQ+J2wCwGc7Lxn5uXmZEozmZvlyFVEw6eFlyo9BwLluTHqd5wh9z+jx2U8dQfnIofrgd2Dp86tGNnvS59L/T/0llP8mbvTZNfusMJiO4gNNlsYhj4lQxUQaDL7gy9fxl8Pqf0eGnpOXluSMpAET1oFY5kKgHbfl6peepZzPQ77LQZDNDkrTwqc47VDNlxkdBmV9mp1R+C6no8Ws1Rkk+xYoNbXy6wVOEZy6VSydOy1OsUPpc1hMALYtkxNs88RBeVi/2uQZ8ssXwyKhTIs4zB0JXnSJbOrnkE/NiR8m6r7Nj21oRPcg0Jihl6gq7nU= winromulus@Maximus\"\n            ]\n        },\n        {\n            \"Username\": \"demo2\",\n            \"Password\": \"demo2\"\n        }\n    ]\n}\n"
  },
  {
    "path": "samples/sample.sftp.json",
    "content": "{\n    \"Global\": {\n        \"Chroot\": {\n            \"Directory\": \"%h\",\n            \"StartPath\": \"sftp\"\n        },\n        \"Directories\": [\"sftp\"]\n    },\n    \"Users\": [\n        {\n            \"Username\": \"demo\",\n            \"Password\": \"demo\"\n        }\n    ]\n}"
  },
  {
    "path": "src/.dockerignore",
    "content": "**/.classpath\r\n**/.dockerignore\r\n**/.env\r\n**/.git\r\n**/.gitignore\r\n**/.project\r\n**/.settings\r\n**/.toolstarget\r\n**/.vs\r\n**/.vscode\r\n**/*.*proj.user\r\n**/*.dbmdl\r\n**/*.jfm\r\n**/azds.yaml\r\n**/bin\r\n**/charts\r\n**/docker-compose*\r\n**/Dockerfile*\r\n**/node_modules\r\n**/npm-debug.log\r\n**/obj\r\n**/secrets.dev.yaml\r\n**/values.dev.yaml\r\nLICENSE\r\nREADME.md"
  },
  {
    "path": "src/ES.SFTP/Api/PamEventsController.cs",
    "content": "﻿using ES.SFTP.Messages.Pam;\r\nusing MediatR;\r\nusing Microsoft.AspNetCore.Mvc;\r\n\r\nnamespace ES.SFTP.Api;\r\n\r\n[Route(\"api/events/pam\")]\r\npublic class PamEventsController : Controller\r\n{\r\n    private readonly ILogger<PamEventsController> _logger;\r\n    private readonly IMediator _mediator;\r\n\r\n    public PamEventsController(ILogger<PamEventsController> logger, IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _mediator = mediator;\r\n    }\r\n\r\n\r\n    [HttpGet]\r\n    [Route(\"generic\")]\r\n    public async Task<IActionResult> OnGenericPamEvent(string username, string type, string service)\r\n    {\r\n        _logger.LogDebug(\"Received event for user '{username}' with type '{type}', {service}\",\r\n            username, type, service);\r\n        var response = await _mediator.Send(new PamEventRequest\r\n        {\r\n            Username = username,\r\n            EventType = type,\r\n            Service = service\r\n        });\r\n        return response ? Ok() : BadRequest();\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/ConfigurationService.cs",
    "content": "﻿using ES.SFTP.Configuration.Elements;\r\nusing ES.SFTP.Messages.Configuration;\r\nusing ES.SFTP.Messages.Events;\r\nusing MediatR;\r\nusing Microsoft.Extensions.Options;\r\n\r\nnamespace ES.SFTP.Configuration;\r\n\r\npublic class ConfigurationService : IHostedService, IRequestHandler<SftpConfigurationRequest, SftpConfiguration>\r\n{\r\n    private readonly ILogger _logger;\r\n    private readonly IMediator _mediator;\r\n    private readonly IOptionsMonitor<SftpConfiguration> _sftpOptionsMonitor;\r\n    private SftpConfiguration _config;\r\n    private IDisposable _sftpOptionsMonitorChangeHandler;\r\n\r\n\r\n    public ConfigurationService(ILogger<ConfigurationService> logger,\r\n        IOptionsMonitor<SftpConfiguration> sftpOptionsMonitor,\r\n        IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _sftpOptionsMonitor = sftpOptionsMonitor;\r\n        _mediator = mediator;\r\n    }\r\n\r\n\r\n    public async Task StartAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Starting\");\r\n        _sftpOptionsMonitorChangeHandler = _sftpOptionsMonitor.OnChange(OnSftpConfigurationChanged);\r\n        await UpdateConfiguration();\r\n\r\n        _logger.LogInformation(\"Started\");\r\n    }\r\n\r\n    public Task StopAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Stopping\");\r\n\r\n        _sftpOptionsMonitorChangeHandler?.Dispose();\r\n        _logger.LogInformation(\"Stopped\");\r\n\r\n        return Task.CompletedTask;\r\n    }\r\n\r\n\r\n    public Task<SftpConfiguration> Handle(SftpConfigurationRequest request, CancellationToken cancellationToken)\r\n    {\r\n        return Task.FromResult(_config);\r\n    }\r\n\r\n    private void OnSftpConfigurationChanged(SftpConfiguration arg1, string arg2)\r\n    {\r\n        _logger.LogInformation(\"SFTP Configuration was changed.\");\r\n        UpdateConfiguration().Wait();\r\n        _mediator.Publish(new ConfigurationChanged()).ConfigureAwait(false);\r\n    }\r\n\r\n    private Task UpdateConfiguration()\r\n    {\r\n        _logger.LogDebug(\"Validating and updating configuration\");\r\n\r\n        var config = _sftpOptionsMonitor.CurrentValue ?? new SftpConfiguration();\r\n\r\n        config.Global ??= new GlobalConfiguration();\r\n\r\n        config.Global.Directories ??= new List<string>();\r\n        config.Global.Logging ??= new LoggingDefinition();\r\n        config.Global.Chroot ??= new ChrootDefinition();\r\n        config.Global.PKIandPassword ??= new string(\"\");\r\n        config.Global.HostKeys ??= new HostKeysDefinition();\r\n        config.Global.Hooks ??= new HooksDefinition();\r\n\r\n        if (string.IsNullOrWhiteSpace(config.Global.Chroot.Directory)) config.Global.Chroot.Directory = \"%h\";\r\n        if (string.IsNullOrWhiteSpace(config.Global.Chroot.StartPath)) config.Global.Chroot.StartPath = null;\r\n\r\n\r\n        config.Users ??= new List<UserDefinition>();\r\n\r\n        var validUsers = new List<UserDefinition>();\r\n        for (var index = 0; index < config.Users.Count; index++)\r\n        {\r\n            var userDefinition = config.Users[index];\r\n            if (string.IsNullOrWhiteSpace(userDefinition.Username))\r\n            {\r\n                _logger.LogWarning(\"Users[{index}] has a null or whitespace username. Skipping user.\", index);\r\n                continue;\r\n            }\r\n\r\n            userDefinition.Chroot ??= new ChrootDefinition();\r\n            if (string.IsNullOrWhiteSpace(userDefinition.Chroot.Directory))\r\n                userDefinition.Chroot.Directory = config.Global.Chroot.Directory;\r\n            if (string.IsNullOrWhiteSpace(userDefinition.Chroot.StartPath))\r\n                userDefinition.Chroot.StartPath = config.Global.Chroot.StartPath;\r\n\r\n            if (userDefinition.Chroot.Directory == config.Global.Chroot.Directory &&\r\n                userDefinition.Chroot.StartPath == config.Global.Chroot.StartPath)\r\n                userDefinition.Chroot = null;\r\n            userDefinition.Directories ??= new List<string>();\r\n\r\n            validUsers.Add(userDefinition);\r\n        }\r\n\r\n        config.Users = validUsers;\r\n        _logger.LogInformation(\"Configuration contains '{userCount}' user(s)\", config.Users.Count);\r\n\r\n        _config = config;\r\n        return Task.CompletedTask;\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/ChrootDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class ChrootDefinition\r\n{\r\n    public string Directory { get; set; } = \"%h\";\r\n    public string StartPath { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/GlobalConfiguration.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class GlobalConfiguration\r\n{\r\n    public ChrootDefinition Chroot { get; set; } = new();\r\n    public List<string> Directories { get; set; } = new();\r\n    public LoggingDefinition Logging { get; set; } = new();\r\n    public HostKeysDefinition HostKeys { get; set; } = new();\r\n    public HooksDefinition Hooks { get; set; } = new();\r\n    public string PKIandPassword { get; set; }\r\n\r\n    public string Ciphers { get; set; }\r\n    public string HostKeyAlgorithms { get; set; }\r\n    public string KexAlgorithms { get; set; }\r\n    public string MACs { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/GroupDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class GroupDefinition\r\n{\r\n    public string Name { get; set; }\r\n    public int? GID { get; set; }\r\n    public List<string> Users { get; set; } = new();\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/HooksDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class HooksDefinition\r\n{\r\n    public List<string> OnServerStartup { get; set; } = new();\r\n    public List<string> OnSessionChange { get; set; } = new();\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/HostKeysDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class HostKeysDefinition\r\n{\r\n    public string Ed25519 { get; set; }\r\n    public string Rsa { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/LoggingDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class LoggingDefinition\r\n{\r\n    public bool IgnoreNoIdentificationString { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/SftpConfiguration.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class SftpConfiguration\r\n{\r\n    public GlobalConfiguration Global { get; set; } = new();\r\n    public List<UserDefinition> Users { get; set; } = new();\r\n    public List<GroupDefinition> Groups { get; set; } = new();\r\n}"
  },
  {
    "path": "src/ES.SFTP/Configuration/Elements/UserDefinition.cs",
    "content": "﻿namespace ES.SFTP.Configuration.Elements;\r\n\r\npublic class UserDefinition\r\n{\r\n    public string Username { get; set; }\r\n    public string Password { get; set; }\r\n    public bool PasswordIsEncrypted { get; set; }\r\n    public List<string> AllowedHosts { get; set; } = new();\r\n\r\n    // ReSharper disable once InconsistentNaming\r\n    public int? UID { get; set; }\r\n\r\n    // ReSharper disable once InconsistentNaming\r\n    public int? GID { get; set; }\r\n    public ChrootDefinition Chroot { get; set; } = new();\r\n    public List<string> Directories { get; set; } = new();\r\n    public List<string> PublicKeys { get; set; } = new();\r\n}"
  },
  {
    "path": "src/ES.SFTP/Dockerfile",
    "content": "#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.\r\n\r\nFROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base\r\nRUN apt-get update && \\\r\n    # - Install required packages\r\n    #\r\n    apt-get -y install members acl iputils-ping nano tini curl && \\\r\n    #\r\n    # - Install openssh-server\r\n    apt-get -y install openssh-server && \\\r\n    #\r\n    # - Install sssd\r\n    apt-get -y install sssd libpam-sss libnss-sss && \\\r\n    #\r\n    # - Cleanup\r\n    rm -rf /var/lib/apt/lists/* && \\\r\n    #\r\n    # - Create OpenSSH directory\r\n    mkdir -p /var/run/sshd && \\\r\n    #\r\n    # - Remove default host keys\r\n    rm -f /etc/ssh/ssh_host_*key*\r\nWORKDIR /app\r\nEXPOSE 22 25080\r\n\r\nFROM mcr.microsoft.com/dotnet/sdk:6.0-bullseye-slim-amd64 AS build\r\nWORKDIR /src\r\nCOPY [\"ES.SFTP/ES.SFTP.csproj\", \"ES.SFTP/\"]\r\nRUN dotnet restore \"ES.SFTP/ES.SFTP.csproj\"\r\nCOPY . .\r\nWORKDIR \"/src/ES.SFTP\"\r\nRUN dotnet build \"ES.SFTP.csproj\" -c Release -o /app/build\r\n\r\nFROM build AS publish\r\nRUN dotnet publish \"ES.SFTP.csproj\" -c Release -o /app/publish\r\n\r\nFROM base AS final\r\nWORKDIR /app\r\nCOPY --from=publish /app/publish .\r\nENTRYPOINT [\"tini\", \"--\", \"dotnet\", \"ES.SFTP.dll\"]"
  },
  {
    "path": "src/ES.SFTP/ES.SFTP.csproj",
    "content": "﻿<Project Sdk=\"Microsoft.NET.Sdk.Web\">\r\n\r\n\t<PropertyGroup>\r\n\t\t<TargetFramework>net6.0</TargetFramework>\r\n\t\t<Nullable>enable</Nullable>\r\n\t\t<ImplicitUsings>enable</ImplicitUsings>\r\n\t\t<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>\r\n\t\t<DockerfileTag>emberstack/sftp:dev</DockerfileTag>\r\n\t\t<DockerfileRunArguments>-p 2222:22 -p 25080:25080 --name sftpdev --privileged</DockerfileRunArguments>\r\n\t\t<ServerGarbageCollection>false</ServerGarbageCollection>\r\n\t</PropertyGroup>\r\n\r\n\t<ItemGroup>\r\n\t\t<None Remove=\"config\\sssd.conf\" />\r\n\t</ItemGroup>\r\n\r\n\t<ItemGroup>\r\n\t\t<_ContentIncludedByDefault Remove=\"app.logging.Development.json\" />\r\n\t\t<_ContentIncludedByDefault Remove=\"app.logging.json\" />\r\n\t\t<_ContentIncludedByDefault Remove=\"config\\sftp.json\" />\r\n\t</ItemGroup>\r\n\r\n\t<ItemGroup>\r\n\t\t<Content Include=\"config\\sssd.conf\">\r\n\t\t\t<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\r\n\t\t</Content>\r\n\t</ItemGroup>\r\n\r\n\t<ItemGroup>\r\n\t\t<PackageReference Include=\"Autofac.Extensions.DependencyInjection\" Version=\"7.2.0\" />\r\n\t\t<PackageReference Include=\"MediatR.Extensions.Microsoft.DependencyInjection\" Version=\"9.0.0\" />\r\n\t\t<PackageReference Include=\"Microsoft.AspNetCore.JsonPatch\" Version=\"6.0.1\" />\r\n\t\t<PackageReference Include=\"Microsoft.VisualStudio.Azure.Containers.Tools.Targets\" Version=\"1.14.0\" />\r\n\t\t<PackageReference Include=\"Serilog.Extensions.Hosting\" Version=\"4.2.0\" />\r\n\t\t<PackageReference Include=\"Serilog.Extensions.Logging\" Version=\"3.1.0\" />\r\n\t\t<PackageReference Include=\"Serilog.Settings.Configuration\" Version=\"3.3.0\" />\r\n\t\t<PackageReference Include=\"Serilog.Sinks.Console\" Version=\"4.0.1\" />\r\n\t</ItemGroup>\r\n\r\n\t<ItemGroup>\r\n\t\t<Content Update=\"appsettings.Development.json\">\r\n\t\t\t<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\r\n\t\t\t<ExcludeFromSingleFile>true</ExcludeFromSingleFile>\r\n\t\t\t<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>\r\n\t\t</Content>\r\n\t\t<Content Update=\"appsettings.json\">\r\n\t\t\t<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>\r\n\t\t\t<ExcludeFromSingleFile>true</ExcludeFromSingleFile>\r\n\t\t\t<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>\r\n\t\t</Content>\r\n\t</ItemGroup>\r\n\r\n</Project>"
  },
  {
    "path": "src/ES.SFTP/Extensions/DirectoryInfoExtensions.cs",
    "content": "﻿namespace ES.SFTP.Extensions;\r\n\r\npublic static class DirectoryInfoExtensions\r\n{\r\n    public static bool IsDescendentOf(this DirectoryInfo directory, DirectoryInfo parent)\r\n    {\r\n        if (parent == null) return false;\r\n        if (directory.Parent == null) return false;\r\n        if (directory.Parent.FullName == parent.FullName) return true;\r\n        return directory.Parent.IsDescendentOf(parent);\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Interop/ProcessRunOutput.cs",
    "content": "﻿namespace ES.SFTP.Interop;\r\n\r\npublic class ProcessRunOutput\r\n{\r\n    public string Output { get; set; }\r\n    public int ExitCode { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Interop/ProcessUtil.cs",
    "content": "﻿using System.Diagnostics;\r\nusing System.Text;\r\n\r\nnamespace ES.SFTP.Interop;\r\n\r\npublic class ProcessUtil\r\n{\r\n    public static Task<ProcessRunOutput> QuickRun(string filename, string arguments = null,\r\n        bool throwOnError = true)\r\n    {\r\n        var outputStringBuilder = new StringBuilder();\r\n        var process = new Process\r\n        {\r\n            StartInfo =\r\n            {\r\n                FileName = filename,\r\n                Arguments = arguments ?? string.Empty,\r\n                UseShellExecute = false,\r\n                RedirectStandardOutput = true,\r\n                RedirectStandardError = true,\r\n                CreateNoWindow = true\r\n            }\r\n        };\r\n        process.OutputDataReceived += (_, e) => outputStringBuilder.Append(e.Data);\r\n        process.ErrorDataReceived += (_, e) => outputStringBuilder.Append(e.Data);\r\n        try\r\n        {\r\n            process.Start();\r\n            process.BeginOutputReadLine();\r\n            process.BeginErrorReadLine();\r\n            process.WaitForExit();\r\n        }\r\n        catch (Exception exception)\r\n        {\r\n            if (throwOnError) throw;\r\n            return Task.FromResult(new ProcessRunOutput\r\n            {\r\n                ExitCode = 1,\r\n                Output = exception.Message\r\n            });\r\n        }\r\n\r\n        var output = outputStringBuilder.ToString();\r\n        if (process.ExitCode != 0 && throwOnError)\r\n            throw new Exception(\r\n                $\"Process failed with exit code '{process.ExitCode}.{Environment.NewLine}{output}'\");\r\n        return Task.FromResult(new ProcessRunOutput\r\n        {\r\n            ExitCode = process.ExitCode,\r\n            Output = output\r\n        });\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Messages/Configuration/SftpConfigurationRequest.cs",
    "content": "﻿using ES.SFTP.Configuration.Elements;\r\nusing MediatR;\r\n\r\nnamespace ES.SFTP.Messages.Configuration;\r\n\r\npublic class SftpConfigurationRequest : IRequest<SftpConfiguration>\r\n{\r\n}"
  },
  {
    "path": "src/ES.SFTP/Messages/Events/ConfigurationChanged.cs",
    "content": "﻿using MediatR;\r\n\r\nnamespace ES.SFTP.Messages.Events;\r\n\r\npublic class ConfigurationChanged : INotification\r\n{\r\n}"
  },
  {
    "path": "src/ES.SFTP/Messages/Events/ServerStartupEvent.cs",
    "content": "﻿using MediatR;\r\n\r\nnamespace ES.SFTP.Messages.Events;\r\n\r\npublic class ServerStartupEvent : INotification\r\n{\r\n}"
  },
  {
    "path": "src/ES.SFTP/Messages/Events/UserSessionStartedEvent.cs",
    "content": "﻿using MediatR;\r\n\r\nnamespace ES.SFTP.Messages.Events;\r\n\r\npublic class UserSessionChangedEvent : INotification\r\n{\r\n    public string Username { get; set; }\r\n    public string SessionState { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Messages/Pam/PamEventRequest.cs",
    "content": "﻿using MediatR;\r\n\r\nnamespace ES.SFTP.Messages.Pam;\r\n\r\npublic class PamEventRequest : IRequest<bool>\r\n{\r\n    public string Username { get; set; }\r\n    public string EventType { get; set; }\r\n    public string Service { get; set; }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Program.cs",
    "content": "using System.Reflection;\r\nusing Autofac;\r\nusing Autofac.Extensions.DependencyInjection;\r\nusing ES.SFTP.Configuration;\r\nusing ES.SFTP.Configuration.Elements;\r\nusing ES.SFTP.Security;\r\nusing ES.SFTP.SSH;\r\nusing MediatR;\r\nusing MediatR.Pipeline;\r\nusing Serilog;\r\n\r\nLog.Logger = new LoggerConfiguration()\r\n    .ReadFrom.Configuration(new ConfigurationBuilder()\r\n        .SetBasePath(Directory.GetCurrentDirectory())\r\n        .AddJsonFile(\"app.logging.json\")\r\n        .AddEnvironmentVariables(nameof(ES))\r\n        .AddCommandLine(args)\r\n        .Build())\r\n    .CreateLogger();\r\n\r\n\r\ntry\r\n{\r\n    Log.Information(\"Starting host\");\r\n\r\n    var builder = WebApplication.CreateBuilder(args);\r\n    builder.Environment.EnvironmentName =\r\n        Environment.GetEnvironmentVariable($\"{nameof(ES)}_{nameof(Environment)}\") ??\r\n        Environments.Production;\r\n\r\n    builder.Configuration.AddJsonFile(\"app.logging.json\", false, false);\r\n    builder.Configuration.AddJsonFile(\"config/sftp.json\", false, true);\r\n    builder.Configuration.AddEnvironmentVariables(nameof(ES));\r\n    builder.Configuration.AddCommandLine(args);\r\n\r\n    builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());\r\n    builder.Host.UseSerilog((hostingContext, loggerConfiguration) => loggerConfiguration\r\n        .ReadFrom.Configuration(hostingContext.Configuration)\r\n        .Enrich.FromLogContext(), true);\r\n    builder.Host.UseConsoleLifetime();\r\n\r\n\r\n    builder.Services.AddHttpClient();\r\n    builder.Services.AddOptions();\r\n    builder.Services.AddHealthChecks();\r\n    builder.Services.AddMediatR(typeof(void).Assembly);\r\n    builder.Services.AddControllers();\r\n\r\n    builder.Services.Configure<SftpConfiguration>(builder.Configuration);\r\n\r\n    builder.Host.ConfigureContainer((ContainerBuilder container) =>\r\n    {\r\n        container.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly).AsImplementedInterfaces();\r\n        container.RegisterGeneric(typeof(RequestPostProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>));\r\n        container.RegisterGeneric(typeof(RequestPreProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>));\r\n        container.Register<ServiceFactory>(ctx =>\r\n        {\r\n            var c = ctx.Resolve<IComponentContext>();\r\n            return t => c.Resolve(t);\r\n        });\r\n\r\n\r\n        container.RegisterType<SessionHandler>().AsImplementedInterfaces().SingleInstance();\r\n        container.RegisterType<HookRunner>().AsImplementedInterfaces().SingleInstance();\r\n        container.RegisterType<ConfigurationService>().AsImplementedInterfaces().SingleInstance();\r\n        container.RegisterType<AuthenticationService>().AsImplementedInterfaces().SingleInstance();\r\n        container.RegisterType<UserManagementService>().AsImplementedInterfaces().SingleInstance();\r\n        container.RegisterType<SSHService>().AsImplementedInterfaces().SingleInstance();\r\n    });\r\n\r\n    builder.WebHost.ConfigureKestrel(options => { options.ListenLocalhost(25080); });\r\n\r\n\r\n    var app = builder.Build();\r\n\r\n    if (!app.Environment.IsDevelopment()) app.UseExceptionHandler(\"/Error\");\r\n\r\n    app.UseStaticFiles();\r\n    app.UseRouting();\r\n    app.UseAuthorization();\r\n    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });\r\n\r\n\r\n    await app.RunAsync();\r\n    return 0;\r\n}\r\ncatch (Exception ex)\r\n{\r\n    Log.Fatal(ex, \"Host terminated unexpectedly\");\r\n    return 1;\r\n}\r\nfinally\r\n{\r\n    Log.CloseAndFlush();\r\n}"
  },
  {
    "path": "src/ES.SFTP/Properties/launchSettings.json",
    "content": "{\r\n  \"$schema\": \"http://json.schemastore.org/launchsettings.json\",\r\n  \"profiles\": {\r\n    \"HOST\": {\r\n      \"commandName\": \"Project\",\r\n      \"launchBrowser\": false,\r\n      \"environmentVariables\": {\r\n        \"SFTP_ENVIRONMENT\": \"Development\"\r\n      },\r\n      \"applicationUrl\": \"http://0.0.0.0:25080\"\r\n    },\r\n    \"Docker\": {\r\n      \"commandName\": \"Docker\",\r\n      \"launchBrowser\": false,\r\n      \"launchUrl\": \"{Scheme}://{ServiceHost}:{ServicePort}\",\r\n      \"publishAllPorts\": true,\r\n      \"environmentVariables\": {\r\n        \"SFTP_ENVIRONMENT\": \"Development\"\r\n      },\r\n      \"httpPort\": 56895\r\n    }\r\n  }\r\n}"
  },
  {
    "path": "src/ES.SFTP/SSH/Configuration/MatchBlock.cs",
    "content": "﻿using System.Text;\r\n\r\nnamespace ES.SFTP.SSH.Configuration;\r\n\r\npublic class MatchBlock\r\n{\r\n    public enum MatchCriteria\r\n    {\r\n        All,\r\n        User,\r\n        Group\r\n    }\r\n\r\n    public MatchCriteria Criteria { get; set; } = MatchCriteria.All;\r\n\r\n    public List<string> Match { get; set; } = new();\r\n    public List<string> Except { get; set; } = new();\r\n    public List<string> Declarations { get; set; } = new();\r\n\r\n    private string GetPatternLine()\r\n    {\r\n        var builder = new StringBuilder();\r\n        builder.Append($\"Match {Criteria} \");\r\n        var patternList = (Match ?? new List<string>()).Where(s => !string.IsNullOrWhiteSpace(s))\r\n            .Select(s => $\"{s.Trim()}\").Distinct().ToList();\r\n        patternList.AddRange((Except ?? new List<string>()).Where(s => !string.IsNullOrWhiteSpace(s))\r\n            .Select(s => $\"!{s.Trim()}\").Distinct().ToList());\r\n        var exceptList = string.Join(\",\", patternList);\r\n        if (!string.IsNullOrWhiteSpace(exceptList)) builder.Append($\"\\\"{exceptList}\\\"\");\r\n        return builder.ToString();\r\n    }\r\n\r\n    public override string ToString()\r\n    {\r\n        var builder = new StringBuilder();\r\n        builder.AppendLine(GetPatternLine());\r\n        foreach (var declaration in (Declarations ?? new List<string>()).Where(declaration =>\r\n            !string.IsNullOrWhiteSpace(declaration)))\r\n            builder.AppendLine(declaration?.Trim());\r\n        return builder.ToString();\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/SSH/Configuration/SSHConfiguration.cs",
    "content": "﻿using System.Text;\r\n\r\nnamespace ES.SFTP.SSH.Configuration;\r\n\r\npublic class SSHConfiguration\r\n{\r\n    public List<MatchBlock> MatchBlocks { get; } = new();\r\n\r\n    public List<string> AllowUsers { get; } = new();\r\n\r\n    public string Ciphers { get; set; }\r\n    public string HostKeyAlgorithms { get; set; }\r\n    public string KexAlgorithms { get; set; }\r\n    public string MACs { get; set; }\r\n    public string PKIandPassword { get; set; }\r\n\r\n    public override string ToString()\r\n    {\r\n        var builder = new StringBuilder();\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"UsePAM yes\");\r\n\r\n        builder.AppendLine(\"# SSH Protocol\");\r\n        builder.AppendLine(\"Protocol 2\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Host Keys\");\r\n        builder.AppendLine(\"HostKey /etc/ssh/ssh_host_ed25519_key\");\r\n        builder.AppendLine(\"HostKey /etc/ssh/ssh_host_rsa_key\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Cryptographic policy\");\r\n        if (!string.IsNullOrWhiteSpace(Ciphers)) builder.AppendLine($\"Ciphers {Ciphers}\");\r\n        if (!string.IsNullOrWhiteSpace(HostKeyAlgorithms)) builder.AppendLine($\"HostKeyAlgorithms {HostKeyAlgorithms}\");\r\n        if (!string.IsNullOrWhiteSpace(KexAlgorithms)) builder.AppendLine($\"KexAlgorithms {KexAlgorithms}\");\r\n        if (!string.IsNullOrWhiteSpace(MACs)) builder.AppendLine($\"MACs {MACs}\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Disable DNS for fast connections\");\r\n        builder.AppendLine(\"UseDNS no\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Logging\");\r\n        builder.AppendLine(\"LogLevel INFO\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Subsystem\");\r\n        builder.AppendLine(\"Subsystem sftp internal-sftp\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Allowed users\");\r\n        builder.AppendLine($\"AllowUsers {string.Join(\" \", AllowUsers)}\");\r\n        builder.AppendLine();\r\n        if (PKIandPassword == \"true\") builder.AppendLine(\"AuthenticationMethods \\\"publickey,password\\\"\");\r\n        builder.AppendLine();\r\n        builder.AppendLine(\"# Match blocks\");\r\n        foreach (var matchBlock in MatchBlocks)\r\n        {\r\n            builder.Append(matchBlock);\r\n            builder.AppendLine();\r\n        }\r\n\r\n        return builder.ToString();\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/SSH/HookRunner.cs",
    "content": "using System.Diagnostics.CodeAnalysis;\r\nusing ES.SFTP.Interop;\r\nusing ES.SFTP.Messages.Configuration;\r\nusing ES.SFTP.Messages.Events;\r\nusing MediatR;\r\n\r\nnamespace ES.SFTP.SSH;\r\n\r\npublic class HookRunner : INotificationHandler<ServerStartupEvent>, INotificationHandler<UserSessionChangedEvent>\r\n{\r\n    private readonly ILogger _logger;\r\n    private readonly IMediator _mediator;\r\n\r\n    public HookRunner(ILogger<HookRunner> logger, IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _mediator = mediator;\r\n    }\r\n\r\n\r\n    [SuppressMessage(\"ReSharper\", \"MethodSupportsCancellation\")]\r\n    public async Task Handle(ServerStartupEvent request, CancellationToken cancellationToken)\r\n    {\r\n        var sftpConfig = await _mediator.Send(new SftpConfigurationRequest());\r\n        var hooks = sftpConfig.Global.Hooks.OnServerStartup ?? new List<string>();\r\n        foreach (var hook in hooks) await RunHook(hook);\r\n    }\r\n\r\n\r\n    [SuppressMessage(\"ReSharper\", \"MethodSupportsCancellation\")]\r\n    public async Task Handle(UserSessionChangedEvent request, CancellationToken cancellationToken)\r\n    {\r\n        var sftpConfig = await _mediator.Send(new SftpConfigurationRequest());\r\n        var hooks = sftpConfig.Global.Hooks.OnSessionChange ?? new List<string>();\r\n        var args = string.Join(' ', request.SessionState, request.Username);\r\n        foreach (var hook in hooks) await RunHook(hook, args);\r\n    }\r\n\r\n    private async Task RunHook(string hook, string args = null)\r\n    {\r\n        if (!File.Exists(hook))\r\n        {\r\n            _logger.LogInformation(\"Hook '{hook}' does not exist\", hook);\r\n            return;\r\n        }\r\n\r\n        var execPermissionOutput = await ProcessUtil.QuickRun(\"bash\",\r\n            $\"-c \\\"if [[ -x {hook} ]]; then echo 'true'; else echo 'false'; fi\\\"\", false);\r\n\r\n        if (execPermissionOutput.ExitCode != 0 ||\r\n            !bool.TryParse(execPermissionOutput.Output, out var isExecutable) ||\r\n            !isExecutable)\r\n            await ProcessUtil.QuickRun(\"chmod\", $\"+x {hook}\");\r\n\r\n        _logger.LogDebug(\"Executing hook '{hook}'\", hook);\r\n        var hookRun = await ProcessUtil.QuickRun(hook, args, false);\r\n\r\n        if (string.IsNullOrWhiteSpace(hookRun.Output))\r\n            _logger.LogDebug(\"Hook '{hook}' completed with exit code {exitCode}.\", hook, hookRun.ExitCode);\r\n        else\r\n            _logger.LogDebug(\r\n                \"Hook '{hook}' completed with exit code {exitCode}.\" +\r\n                $\"{Environment.NewLine}Output:{Environment.NewLine}{{output}}\",\r\n                hook, hookRun.ExitCode, hookRun.Output);\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/SSH/SSHService.cs",
    "content": "﻿using System.Diagnostics;\r\nusing ES.SFTP.Configuration.Elements;\r\nusing ES.SFTP.Interop;\r\nusing ES.SFTP.Messages.Configuration;\r\nusing ES.SFTP.Messages.Events;\r\nusing ES.SFTP.SSH.Configuration;\r\nusing MediatR;\r\n\r\nnamespace ES.SFTP.SSH;\r\n\r\npublic class SSHService : IHostedService, INotificationHandler<ConfigurationChanged>\r\n{\r\n    private const string SshDirPath = \"/etc/ssh\";\r\n    private static readonly string KeysImportDirPath = Path.Combine(SshDirPath, \"keys\");\r\n    private static readonly string ConfigFilePath = Path.Combine(SshDirPath, \"sshd_config\");\r\n    private readonly ILogger<SSHService> _logger;\r\n    private readonly IMediator _mediator;\r\n    private bool _loggingIgnoreNoIdentificationString;\r\n    private Process _serverProcess;\r\n    private Action _serviceProcessExitAction;\r\n\r\n\r\n    public SSHService(ILogger<SSHService> logger, IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _mediator = mediator;\r\n    }\r\n\r\n    public async Task StartAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Starting\");\r\n        await RestartService(true);\r\n        _logger.LogInformation(\"Started\");\r\n    }\r\n\r\n    public async Task StopAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Stopping\");\r\n        await StopOpenSSH();\r\n        _logger.LogInformation(\"Stopped\");\r\n    }\r\n\r\n    public async Task Handle(ConfigurationChanged notification, CancellationToken cancellationToken)\r\n    {\r\n        await RestartService();\r\n    }\r\n\r\n    private async Task RestartService(bool forceStop = false)\r\n    {\r\n        await StopOpenSSH(forceStop);\r\n        await UpdateHostKeyFiles();\r\n        await UpdateConfiguration();\r\n        await StartOpenSSH();\r\n    }\r\n\r\n\r\n    private async Task UpdateConfiguration()\r\n    {\r\n        var sftpConfig = await _mediator.Send(new SftpConfigurationRequest());\r\n        _loggingIgnoreNoIdentificationString = sftpConfig.Global.Logging.IgnoreNoIdentificationString;\r\n\r\n        var sshdConfig = new SSHConfiguration\r\n        {\r\n            Ciphers = sftpConfig.Global.Ciphers,\r\n            HostKeyAlgorithms = sftpConfig.Global.HostKeyAlgorithms,\r\n            KexAlgorithms = sftpConfig.Global.KexAlgorithms,\r\n            MACs = sftpConfig.Global.MACs,\r\n            PKIandPassword = sftpConfig.Global.PKIandPassword\r\n        };\r\n\r\n        var exceptionalUsers = sftpConfig.Users.Where(s => s.Chroot != null).ToList();\r\n\r\n        var standardDeclarations = new[]\r\n        {\r\n            \"X11Forwarding no\",\r\n            \"AllowTcpForwarding no\"\r\n        };\r\n\r\n        sshdConfig.AllowUsers.AddRange(sftpConfig.Users.Select(s =>\r\n            s.AllowedHosts.Any()\r\n                ? $\"{s.Username}@{string.Join(\",\", s.AllowedHosts)}\"\r\n                : s.Username)\r\n        );\r\n\r\n        sshdConfig.MatchBlocks.AddRange(exceptionalUsers.Select(s => new MatchBlock\r\n        {\r\n            Criteria = MatchBlock.MatchCriteria.User,\r\n            Match = {s.Username},\r\n            Declarations = new List<string>(standardDeclarations)\r\n            {\r\n                $\"ChrootDirectory {s.Chroot.Directory}\",\r\n                !string.IsNullOrWhiteSpace(s.Chroot.StartPath)\r\n                    ? $\"ForceCommand internal-sftp -d {s.Chroot.StartPath}\"\r\n                    : \"ForceCommand internal-sftp\"\r\n            }\r\n        }));\r\n\r\n        sshdConfig.MatchBlocks.Add(new MatchBlock\r\n        {\r\n            Criteria = MatchBlock.MatchCriteria.User,\r\n            Match = {\"*\"},\r\n            //Except = exceptionalUsers.Select(s => s.Username).ToList(),\r\n            Declarations = new List<string>(standardDeclarations)\r\n            {\r\n                $\"ChrootDirectory {sftpConfig.Global.Chroot.Directory}\",\r\n                !string.IsNullOrWhiteSpace(sftpConfig.Global.Chroot.StartPath)\r\n                    ? $\"ForceCommand internal-sftp -d {sftpConfig.Global.Chroot.StartPath}\"\r\n                    : \"ForceCommand internal-sftp\"\r\n            }\r\n        });\r\n\r\n        var resultingConfig = sshdConfig.ToString();\r\n        await File.WriteAllTextAsync(ConfigFilePath, resultingConfig);\r\n    }\r\n\r\n    private async Task UpdateHostKeyFiles()\r\n    {\r\n        var config = await _mediator.Send(new SftpConfigurationRequest());\r\n        _logger.LogDebug(\"Updating host key files\");\r\n        Directory.CreateDirectory(KeysImportDirPath);\r\n\r\n        var hostKeys = new[]\r\n        {\r\n            new\r\n            {\r\n                Type = nameof(HostKeysDefinition.Ed25519),\r\n                KeygenArgs = \"-t ed25519 -f {0} -N \\\"\\\"\",\r\n                File = \"ssh_host_ed25519_key\"\r\n            },\r\n            new\r\n            {\r\n                Type = nameof(HostKeysDefinition.Rsa),\r\n                KeygenArgs = \"-t rsa -b 4096 -f {0} -N \\\"\\\"\",\r\n                File = \"ssh_host_rsa_key\"\r\n            }\r\n        };\r\n\r\n        foreach (var hostKeyType in hostKeys)\r\n        {\r\n            var filePath = Path.Combine(KeysImportDirPath, hostKeyType.File);\r\n            if (File.Exists(filePath)) continue;\r\n            var configValue = (string) config.Global.HostKeys.GetType().GetProperty(hostKeyType.Type)\r\n                ?.GetValue(config.Global.HostKeys, null);\r\n\r\n            if (!string.IsNullOrWhiteSpace(configValue))\r\n            {\r\n                _logger.LogDebug(\"Writing host key file '{file}' from config\", filePath);\r\n                await File.WriteAllTextAsync(filePath, configValue);\r\n            }\r\n            else\r\n            {\r\n                _logger.LogDebug(\"Generating host key file '{file}'\", filePath);\r\n                var keygenArgs = string.Format(hostKeyType.KeygenArgs, filePath);\r\n                await ProcessUtil.QuickRun(\"ssh-keygen\", keygenArgs);\r\n            }\r\n        }\r\n\r\n        foreach (var file in Directory.GetFiles(KeysImportDirPath))\r\n        {\r\n            var targetFile = Path.Combine(SshDirPath, Path.GetFileName(file));\r\n            _logger.LogDebug(\"Copying '{sourceFile}' to '{targetFile}'\", file, targetFile);\r\n            File.Copy(file, targetFile, true);\r\n            await ProcessUtil.QuickRun(\"chown\", $\"root:root \\\"{targetFile}\\\"\");\r\n            await ProcessUtil.QuickRun(\"chmod\", $\"700 \\\"{targetFile}\\\"\");\r\n        }\r\n    }\r\n\r\n\r\n    private async Task StartOpenSSH()\r\n    {\r\n        _logger.LogInformation(\"Starting 'sshd' process\");\r\n        _serviceProcessExitAction = () =>\r\n        {\r\n            _logger.LogWarning(\"'sshd' process has stopped. Restarting process.\");\r\n            RestartService().Wait();\r\n        };\r\n\r\n        void ListenForExit()\r\n        {\r\n            //Use this approach since the Exited event does not trigger on process crash\r\n            Task.Run(() =>\r\n            {\r\n                _serverProcess.WaitForExit();\r\n                _serviceProcessExitAction?.Invoke();\r\n            });\r\n        }\r\n\r\n        _serverProcess = new Process\r\n        {\r\n            StartInfo =\r\n            {\r\n                FileName = \"/usr/sbin/sshd\",\r\n                Arguments = \"-D -e\",\r\n                UseShellExecute = false,\r\n                RedirectStandardOutput = true,\r\n                RedirectStandardError = true,\r\n                CreateNoWindow = true\r\n            }\r\n        };\r\n        _serverProcess.OutputDataReceived -= OnSSHOutput;\r\n        _serverProcess.ErrorDataReceived -= OnSSHOutput;\r\n        _serverProcess.OutputDataReceived += OnSSHOutput;\r\n        _serverProcess.ErrorDataReceived += OnSSHOutput;\r\n        _serverProcess.Start();\r\n        ListenForExit();\r\n        _serverProcess.BeginOutputReadLine();\r\n        _serverProcess.BeginErrorReadLine();\r\n        await _mediator.Publish(new ServerStartupEvent());\r\n    }\r\n\r\n    private void OnSSHOutput(object sender, DataReceivedEventArgs e)\r\n    {\r\n        if (string.IsNullOrWhiteSpace(e.Data)) return;\r\n        if (_loggingIgnoreNoIdentificationString &&\r\n            e.Data.Trim().StartsWith(\"Did not receive identification string from\")) return;\r\n        _logger.LogTrace($\"sshd - {e.Data}\");\r\n    }\r\n\r\n    private async Task StopOpenSSH(bool force = false)\r\n    {\r\n        if (_serverProcess != null)\r\n        {\r\n            _logger.LogDebug(\"Stopping 'sshd' process\");\r\n            _serviceProcessExitAction = null;\r\n            _serverProcess.Kill(true);\r\n            _serverProcess.OutputDataReceived -= OnSSHOutput;\r\n            _serverProcess.ErrorDataReceived -= OnSSHOutput;\r\n            _logger.LogInformation(\"Stopped 'sshd' process\");\r\n            _serverProcess.Dispose();\r\n            _serverProcess = null;\r\n        }\r\n\r\n        if (force)\r\n        {\r\n            var arguments = Debugger.IsAttached ? \"-q sshd\" : \"-q -w sshd\";\r\n            var command = await ProcessUtil.QuickRun(\"killall\", arguments, false);\r\n            if (command.ExitCode != 0 && command.ExitCode != 1 && !string.IsNullOrWhiteSpace(command.Output))\r\n                throw new Exception(\r\n                    $\"Could not stop existing sshd processes.{Environment.NewLine}{command.Output}\");\r\n        }\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/SSH/SessionHandler.cs",
    "content": "using System.Diagnostics.CodeAnalysis;\r\nusing ES.SFTP.Configuration.Elements;\r\nusing ES.SFTP.Extensions;\r\nusing ES.SFTP.Interop;\r\nusing ES.SFTP.Messages.Configuration;\r\nusing ES.SFTP.Messages.Events;\r\nusing ES.SFTP.Messages.Pam;\r\nusing MediatR;\r\n\r\nnamespace ES.SFTP.SSH;\r\n\r\npublic class SessionHandler : IRequestHandler<PamEventRequest, bool>\r\n{\r\n    private const string HomeBasePath = \"/home\";\r\n    private const string SftpUserInventoryGroup = \"sftp-user-inventory\";\r\n\r\n    private readonly ILogger _logger;\r\n    private readonly IMediator _mediator;\r\n    private SftpConfiguration _config;\r\n\r\n    public SessionHandler(ILogger<SessionHandler> logger, IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _mediator = mediator;\r\n    }\r\n\r\n\r\n    [SuppressMessage(\"ReSharper\", \"MethodSupportsCancellation\")]\r\n    public async Task<bool> Handle(PamEventRequest request, CancellationToken cancellationToken)\r\n    {\r\n        switch (request.EventType)\r\n        {\r\n            case \"open_session\":\r\n                await PrepareUserForSftp(request.Username);\r\n                break;\r\n        }\r\n\r\n        await _mediator.Publish(new UserSessionChangedEvent\r\n        {\r\n            Username = request.Username,\r\n            SessionState = request.EventType\r\n        });\r\n        return true;\r\n    }\r\n\r\n    private async Task PrepareUserForSftp(string username)\r\n    {\r\n        _logger.LogDebug(\"Configuring session for user '{user}'\", username);\r\n\r\n        _config = await _mediator.Send(new SftpConfigurationRequest());\r\n\r\n        var user = _config.Users.FirstOrDefault(s => s.Username == username) ?? new UserDefinition\r\n        {\r\n            Username = username,\r\n            Chroot = _config.Global.Chroot,\r\n            Directories = _config.Global.Directories\r\n        };\r\n\r\n        var homeDirPath = Path.Combine(HomeBasePath, username);\r\n        var chroot = user.Chroot ?? _config.Global.Chroot;\r\n\r\n        //Parse chroot path by replacing markers\r\n        var chrootPath = string.Join(\"%%h\",\r\n            chroot.Directory.Split(\"%%h\")\r\n                .Select(s => s.Replace(\"%h\", homeDirPath)).ToList());\r\n        chrootPath = string.Join(\"%%u\",\r\n            chrootPath.Split(\"%%u\")\r\n                .Select(s => s.Replace(\"%u\", username)).ToList());\r\n\r\n        //Create chroot directory and set owner to root and correct permissions\r\n        var chrootDirectory = Directory.CreateDirectory(chrootPath);\r\n        await ProcessUtil.QuickRun(\"chown\", $\"root:root {chrootDirectory.FullName}\");\r\n        await ProcessUtil.QuickRun(\"chmod\", $\"755 {chrootDirectory.FullName}\");\r\n\r\n        var directories = new List<string>();\r\n        directories.AddRange(_config.Global.Directories);\r\n        directories.AddRange(user.Directories);\r\n        foreach (var directory in directories.Distinct().OrderBy(s => s).ToList())\r\n        {\r\n            var dirInfo = new DirectoryInfo(Path.Combine(chrootDirectory.FullName, directory));\r\n            if (!dirInfo.Exists)\r\n            {\r\n                _logger.LogDebug(\"Creating directory '{dir}' for user '{user}'\", dirInfo.FullName, username);\r\n                Directory.CreateDirectory(dirInfo.FullName);\r\n            }\r\n\r\n            try\r\n            {\r\n                if (dirInfo.IsDescendentOf(chrootDirectory))\r\n                {\r\n                    //Set the user as owner for directory and all parents until chroot path\r\n                    var dir = dirInfo;\r\n                    while (dir.FullName != chrootDirectory.FullName)\r\n                    {\r\n                        await ProcessUtil.QuickRun(\"chown\", $\"{username}:{SftpUserInventoryGroup} {dir.FullName}\");\r\n                        dir = dir.Parent ?? chrootDirectory;\r\n                    }\r\n                }\r\n                else\r\n                {\r\n                    _logger.LogWarning(\r\n                        \"Directory '{dir}' is not within chroot path '{chroot}'. Setting direct permissions.\",\r\n                        dirInfo.FullName, chrootDirectory.FullName);\r\n\r\n                    await ProcessUtil.QuickRun(\"chown\",\r\n                        $\"{username}:{SftpUserInventoryGroup} {dirInfo.FullName}\");\r\n                }\r\n            }\r\n            catch (Exception exception)\r\n            {\r\n                _logger.LogWarning(exception, \"Exception occured while setting permissions for '{dir}' \",\r\n                    dirInfo.FullName);\r\n            }\r\n        }\r\n\r\n        _logger.LogInformation(\"Session ready for user '{user}'\", username);\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Security/AuthenticationService.cs",
    "content": "﻿using System.Text;\r\nusing ES.SFTP.Interop;\r\n\r\nnamespace ES.SFTP.Security;\r\n\r\npublic class AuthenticationService : IHostedService\r\n{\r\n    private const string PamDirPath = \"/etc/pam.d\";\r\n    private const string PamHookName = \"sftp-hook\";\r\n    private readonly ILogger _logger;\r\n\r\n    public AuthenticationService(ILogger<AuthenticationService> logger)\r\n    {\r\n        _logger = logger;\r\n    } // ReSharper disable MethodSupportsCancellation\r\n    public async Task StartAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Starting\");\r\n\r\n        var pamCommonSessionFile = Path.Combine(PamDirPath, \"common-session\");\r\n        var pamSftpHookFile = Path.Combine(PamDirPath, PamHookName);\r\n\r\n        _logger.LogDebug(\"Stopping SSSD service\");\r\n        await ProcessUtil.QuickRun(\"service\", \"sssd stop\", false);\r\n\r\n        _logger.LogDebug(\"Applying SSSD configuration\");\r\n        File.Copy(\"./config/sssd.conf\", \"/etc/sssd/sssd.conf\", true);\r\n        await ProcessUtil.QuickRun(\"chown\", \"root:root \\\"/etc/sssd/sssd.conf\\\"\");\r\n        await ProcessUtil.QuickRun(\"chmod\", \"600 \\\"/etc/sssd/sssd.conf\\\"\");\r\n\r\n        _logger.LogDebug(\"Installing PAM hook\");\r\n        var scriptsDirectory = Path.Combine(PamDirPath, \"scripts\");\r\n        if (!Directory.Exists(scriptsDirectory)) Directory.CreateDirectory(scriptsDirectory);\r\n        var hookScriptFile = Path.Combine(new DirectoryInfo(scriptsDirectory).FullName, \"sftp-pam-event.sh\");\r\n        var eventsScriptBuilder = new StringBuilder();\r\n        eventsScriptBuilder.AppendLine(\"#!/bin/sh\");\r\n        eventsScriptBuilder.AppendLine(\r\n            \"curl \\\"http://localhost:25080/api/events/pam/generic?username=$PAM_USER&type=$PAM_TYPE&service=$PAM_SERVICE\\\"\");\r\n        await File.WriteAllTextAsync(hookScriptFile, eventsScriptBuilder.ToString());\r\n        await ProcessUtil.QuickRun(\"chown\", $\"root:root \\\"{hookScriptFile}\\\"\");\r\n        await ProcessUtil.QuickRun(\"chmod\", $\"+x \\\"{hookScriptFile}\\\"\");\r\n\r\n\r\n        var hookBuilder = new StringBuilder();\r\n        hookBuilder.AppendLine(\"# This file is used to signal the SFTP service on user events.\");\r\n        hookBuilder.AppendLine($\"session required pam_exec.so {new FileInfo(hookScriptFile).FullName}\");\r\n        await File.WriteAllTextAsync(pamSftpHookFile, hookBuilder.ToString());\r\n        await ProcessUtil.QuickRun(\"chown\", $\"root:root \\\"{pamSftpHookFile}\\\"\");\r\n        await ProcessUtil.QuickRun(\"chmod\", $\"644 \\\"{pamSftpHookFile}\\\"\");\r\n\r\n\r\n        if (!(await File.ReadAllTextAsync(pamCommonSessionFile)).Contains($\"@include {PamHookName}\"))\r\n            await File.AppendAllTextAsync(pamCommonSessionFile, $\"@include {PamHookName}{Environment.NewLine}\");\r\n\r\n        _logger.LogDebug(\"Restarting SSSD service\");\r\n        await ProcessUtil.QuickRun(\"service\", \"sssd restart\", false);\r\n\r\n        _logger.LogInformation(\"Started\");\r\n    }\r\n\r\n    public async Task StopAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Stopping\");\r\n        await ProcessUtil.QuickRun(\"service\", \"sssd stop\", false);\r\n        _logger.LogInformation(\"Stopped\");\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Security/GroupUtil.cs",
    "content": "﻿using ES.SFTP.Interop;\r\n\r\nnamespace ES.SFTP.Security;\r\n\r\npublic class GroupUtil\r\n{\r\n    public static async Task<bool> GroupExists(string groupNameOrId)\r\n    {\r\n        var command = await ProcessUtil.QuickRun(\"getent\", $\"group {groupNameOrId}\", false);\r\n        return command.ExitCode == 0 && !string.IsNullOrWhiteSpace(command.Output);\r\n    }\r\n\r\n    public static async Task GroupCreate(string name, bool force = false, int? groupId = null,\r\n        bool nonUniqueGroupId = true)\r\n    {\r\n        await ProcessUtil.QuickRun(\"groupadd\",\r\n            $\"{(force ? \"-f\" : string.Empty)} {(groupId != null ? $\"-g {groupId} {(nonUniqueGroupId ? \"-o\" : string.Empty)}\" : string.Empty)} {name}\");\r\n    }\r\n\r\n    public static async Task GroupAddUser(string group, string username)\r\n    {\r\n        await ProcessUtil.QuickRun(\"usermod\", $\"-a -G {group} {username}\");\r\n    }\r\n\r\n\r\n    public static async Task GroupRemoveUser(string group, string username)\r\n    {\r\n        await ProcessUtil.QuickRun(\"usermod\", $\"-G {group} {username}\");\r\n    }\r\n\r\n    public static async Task<IReadOnlyList<string>> GroupListUsers(string group)\r\n    {\r\n        var command = await ProcessUtil.QuickRun(\"members\", group, false);\r\n        if (command.ExitCode != 0 && command.ExitCode != 1 && !string.IsNullOrWhiteSpace(command.Output))\r\n            throw new Exception($\"Get group members command failed with exit code {command.ExitCode} and message:\" +\r\n                                $\"{Environment.NewLine}{command.Output}\");\r\n        return command.Output.Split(' ', StringSplitOptions.RemoveEmptyEntries).OrderBy(s => s).ToList();\r\n    }\r\n\r\n    public static async Task<int> GroupGetId(string groupNameOrId)\r\n    {\r\n        var command = await ProcessUtil.QuickRun(\"getent\", $\"group {groupNameOrId}\");\r\n        var groupEntryValues = command.Output.Split(\":\");\r\n        return int.Parse(groupEntryValues[2]);\r\n    }\r\n\r\n    public static async Task GroupSetId(string groupNameOrId, int id)\r\n    {\r\n        await ProcessUtil.QuickRun(\"groupmod\", $\"-g {id} {groupNameOrId}\");\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Security/UserManagementService.cs",
    "content": "﻿using System.Diagnostics.CodeAnalysis;\r\nusing System.Text;\r\nusing ES.SFTP.Interop;\r\nusing ES.SFTP.Messages.Configuration;\r\nusing ES.SFTP.Messages.Events;\r\nusing MediatR;\r\n\r\nnamespace ES.SFTP.Security;\r\n\r\npublic class UserManagementService : IHostedService, INotificationHandler<ConfigurationChanged>\r\n{\r\n    private const string HomeBasePath = \"/home\";\r\n    private const string SftpUserInventoryGroup = \"sftp-user-inventory\";\r\n    private readonly ILogger _logger;\r\n    private readonly IMediator _mediator;\r\n\r\n    public UserManagementService(ILogger<UserManagementService> logger, IMediator mediator)\r\n    {\r\n        _logger = logger;\r\n        _mediator = mediator;\r\n    }\r\n\r\n\r\n    [SuppressMessage(\"ReSharper\", \"MethodSupportsCancellation\")]\r\n    public async Task StartAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Starting\");\r\n\r\n\r\n        _logger.LogDebug(\"Ensuring '{home}' directory exists and has correct permissions\", HomeBasePath);\r\n        Directory.CreateDirectory(HomeBasePath);\r\n        await ProcessUtil.QuickRun(\"chown\", $\"root:root \\\"{HomeBasePath}\\\"\");\r\n\r\n        _logger.LogDebug(\"Ensuring group '{group}' exists\", SftpUserInventoryGroup);\r\n        if (!await GroupUtil.GroupExists(SftpUserInventoryGroup))\r\n        {\r\n            _logger.LogInformation(\"Creating group '{group}'\", SftpUserInventoryGroup);\r\n            await GroupUtil.GroupCreate(SftpUserInventoryGroup, true);\r\n        }\r\n\r\n        await SyncUsersAndGroups();\r\n        _logger.LogInformation(\"Started\");\r\n    }\r\n\r\n    [SuppressMessage(\"ReSharper\", \"MethodSupportsCancellation\")]\r\n    public Task StopAsync(CancellationToken cancellationToken)\r\n    {\r\n        _logger.LogDebug(\"Stopping\");\r\n        _logger.LogInformation(\"Stopped\");\r\n        return Task.CompletedTask;\r\n    }\r\n\r\n    public async Task Handle(ConfigurationChanged notification, CancellationToken cancellationToken)\r\n    {\r\n        await SyncUsersAndGroups();\r\n    }\r\n\r\n    private async Task SyncUsersAndGroups()\r\n    {\r\n        var config = await _mediator.Send(new SftpConfigurationRequest());\r\n\r\n        _logger.LogInformation(\"Synchronizing users and groups\");\r\n\r\n\r\n        //Remove users that do not exist in config anymore\r\n        var existingUsers = await GroupUtil.GroupListUsers(SftpUserInventoryGroup);\r\n        var toRemove = existingUsers.Where(s => !config.Users.Select(t => t.Username).Contains(s)).ToList();\r\n        foreach (var user in toRemove)\r\n        {\r\n            _logger.LogDebug(\"Removing user '{user}'\", user, SftpUserInventoryGroup);\r\n            await UserUtil.UserDelete(user, false);\r\n        }\r\n\r\n        //Create groups as specified by the GID value for each user\r\n        foreach (var user in config.Users)\r\n        {\r\n            if (user.GID.HasValue)\r\n            {\r\n                _logger.LogInformation(\"Processing GID for user '{user}'\", user.Username);\r\n\r\n                var virtualGroup = $\"sftp-gid-{user.GID.Value}\";\r\n                if (!await GroupUtil.GroupExists(virtualGroup))\r\n                {\r\n                    _logger.LogDebug(\"Creating group '{group}' with GID '{gid}'\", virtualGroup, user.GID.Value);\r\n                    await GroupUtil.GroupCreate(virtualGroup, true, user.GID.Value);\r\n                }\r\n            }\r\n        }\r\n\r\n        foreach (var user in config.Users)\r\n        {\r\n            _logger.LogInformation(\"Processing user '{user}'\", user.Username);\r\n\r\n            if (!await UserUtil.UserExists(user.Username))\r\n            {\r\n                _logger.LogDebug(\"Creating user '{user}'\", user.Username);\r\n                await UserUtil.UserCreate(user.Username, true, user.GID);\r\n                _logger.LogDebug(\"Adding user '{user}' to '{group}'\", user.Username, SftpUserInventoryGroup);\r\n                await GroupUtil.GroupAddUser(SftpUserInventoryGroup, user.Username);\r\n            }\r\n\r\n\r\n            _logger.LogDebug(\"Updating the password for user '{user}'\", user.Username);\r\n            await UserUtil.UserSetPassword(user.Username, user.Password, user.PasswordIsEncrypted);\r\n\r\n            if (user.UID.HasValue && await UserUtil.UserGetId(user.Username) != user.UID.Value)\r\n            {\r\n                _logger.LogDebug(\"Updating the UID for user '{user}'\", user.Username);\r\n                await UserUtil.UserSetId(user.Username, user.UID.Value);\r\n            }\r\n\r\n            var homeDir = Directory.CreateDirectory(Path.Combine(HomeBasePath, user.Username));\r\n            await ProcessUtil.QuickRun(\"chown\", $\"root:root {homeDir.FullName}\");\r\n            await ProcessUtil.QuickRun(\"chmod\", $\"711 {homeDir.FullName}\");\r\n\r\n            var sshDir = Directory.CreateDirectory(Path.Combine(homeDir.FullName, \".ssh\"));\r\n            var sshKeysDir = Directory.CreateDirectory(Path.Combine(sshDir.FullName, \"keys\"));\r\n            var sshAuthKeysPath = Path.Combine(sshDir.FullName, \"authorized_keys\");\r\n            if (File.Exists(sshAuthKeysPath)) File.Delete(sshAuthKeysPath);\r\n            var authKeysBuilder = new StringBuilder();\r\n            foreach (var file in Directory.GetFiles(sshKeysDir.FullName))\r\n            {\r\n                _logger.LogDebug(\"Adding public key '{file}' for user '{user}'\", file, user.Username);\r\n                authKeysBuilder.AppendLine(await File.ReadAllTextAsync(file));\r\n            }\r\n\r\n            foreach (var publicKey in user.PublicKeys)\r\n            {\r\n                _logger.LogDebug(\"Adding public key from config for user '{user}'\", user.Username);\r\n                authKeysBuilder.AppendLine(publicKey);\r\n            }\r\n\r\n            await File.WriteAllTextAsync(sshAuthKeysPath, authKeysBuilder.ToString());\r\n            await ProcessUtil.QuickRun(\"chown\", $\"{user.Username} {sshAuthKeysPath}\");\r\n            await ProcessUtil.QuickRun(\"chmod\", $\"400 {sshAuthKeysPath}\");\r\n        }\r\n\r\n\r\n        foreach (var groupDefinition in config.Groups)\r\n        {\r\n            _logger.LogInformation(\"Processing group '{group}'\", groupDefinition.Name);\r\n\r\n            var groupUsers = groupDefinition.Users ?? new List<string>();\r\n            if (!await GroupUtil.GroupExists(groupDefinition.Name))\r\n            {\r\n                _logger.LogDebug(\"Creating group '{group}' with GID '{gid}'\", groupDefinition.Name,\r\n                    groupDefinition.GID);\r\n                await GroupUtil.GroupCreate(groupDefinition.Name, true, groupDefinition.GID);\r\n            }\r\n\r\n            if (groupDefinition.GID.HasValue)\r\n            {\r\n                var currentId = await GroupUtil.GroupGetId(groupDefinition.Name);\r\n                if (currentId != groupDefinition.GID.Value)\r\n                {\r\n                    _logger.LogDebug(\"Updating group '{group}' with GID '{gid}'\", groupDefinition.Name,\r\n                        groupDefinition.GID);\r\n                    await GroupUtil.GroupSetId(groupDefinition.Name, groupDefinition.GID.Value);\r\n                }\r\n            }\r\n\r\n            var members = await GroupUtil.GroupListUsers(groupDefinition.Name);\r\n            var toAdd = groupUsers.Where(s => !members.Contains(s)).ToList();\r\n            foreach (var user in toAdd)\r\n            {\r\n                if (!await UserUtil.UserExists(user)) continue;\r\n                _logger.LogDebug(\"Adding user '{user}' to '{group}'\", user, groupDefinition.Name);\r\n                await GroupUtil.GroupAddUser(groupDefinition.Name, user);\r\n            }\r\n\r\n            members = await GroupUtil.GroupListUsers(groupDefinition.Name);\r\n            var usersToRemove = members.Where(s => !groupUsers.Contains(s)).ToList();\r\n            foreach (var user in usersToRemove)\r\n            {\r\n                _logger.LogDebug(\"Removing user '{user}'\", user, groupDefinition.Name);\r\n                await GroupUtil.GroupRemoveUser(groupDefinition.Name, user);\r\n            }\r\n        }\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/Security/UserUtil.cs",
    "content": "﻿using ES.SFTP.Interop;\r\n\r\nnamespace ES.SFTP.Security;\r\n\r\npublic class UserUtil\r\n{\r\n    public static async Task<bool> UserExists(string username)\r\n    {\r\n        var command = await ProcessUtil.QuickRun(\"getent\", $\"passwd {username}\", false);\r\n        return command.ExitCode == 0 && !string.IsNullOrWhiteSpace(command.Output);\r\n    }\r\n\r\n    public static async Task UserCreate(string username, bool noLoginShell = false, int? gid = null)\r\n    {\r\n        await ProcessUtil.QuickRun(\"useradd\",\r\n            $\"--comment {username} {(noLoginShell ? \"-s /usr/sbin/nologin \" : string.Empty)}{(gid.HasValue ? \"-g \" + gid.Value + \" \" : string.Empty)}{username}\");\r\n    }\r\n\r\n    public static async Task UserDelete(string username, bool throwOnError = true)\r\n    {\r\n        await ProcessUtil.QuickRun(\"userdel\", username, throwOnError);\r\n    }\r\n\r\n    public static async Task UserSetId(string username, int id, bool nonUnique = true)\r\n    {\r\n        await ProcessUtil.QuickRun(\"pkill\", $\"-U {await UserGetId(username)}\", false);\r\n        await ProcessUtil.QuickRun(\"usermod\",\r\n            $\"{(nonUnique ? \"--non-unique\" : string.Empty)} --uid {id} {username}\");\r\n    }\r\n\r\n    public static async Task UserSetPassword(string username, string password, bool passwordIsEncrypted)\r\n    {\r\n        if (string.IsNullOrEmpty(password))\r\n            await ProcessUtil.QuickRun(\"usermod\", $\"-p \\\"*\\\" {username}\");\r\n        else\r\n            await ProcessUtil.QuickRun(\"bash\",\r\n                $\"-c \\\"echo '{username}:{password}' | chpasswd {(passwordIsEncrypted ? \"-e\" : string.Empty)}\\\"\");\r\n    }\r\n\r\n    public static async Task<int> UserGetId(string username)\r\n    {\r\n        var command = await ProcessUtil.QuickRun(\"id\", $\"-u {username}\");\r\n        return int.Parse(command.Output);\r\n    }\r\n}"
  },
  {
    "path": "src/ES.SFTP/app.logging.Development.json",
    "content": "{\r\n  \"Logging\": {\r\n    \"LogLevel\": {\r\n      \"Default\": \"Debug\",\r\n      \"System\": \"Information\",\r\n      \"Microsoft\": \"Information\"\r\n    }\r\n  }\r\n}"
  },
  {
    "path": "src/ES.SFTP/app.logging.json",
    "content": "{\r\n  \"Serilog\": {\r\n    \"Using\": [\"Serilog.Sinks.Console\"],\r\n    \"MinimumLevel\": {\r\n      \"Default\": \"Verbose\",\r\n      \"Override\": {\r\n        \"Microsoft.AspNetCore.Server.Kestrel\": \"Error\",\r\n        \"Microsoft\": \"Warning\",\r\n        \"System\": \"Warning\",\r\n        \"Microsoft.Extensions.Http\": \"Warning\"\r\n      }\r\n    },\r\n    \"WriteTo\": [\r\n      {\r\n        \"Name\": \"Console\",\r\n        \"Args\": {\r\n          \"outputTemplate\":\r\n            \"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] ({SourceContext}) {Message:lj}{NewLine}{Exception}\"\r\n        }\r\n      }\r\n    ]\r\n  }\r\n}"
  },
  {
    "path": "src/ES.SFTP/appsettings.Development.json",
    "content": "{\r\n}"
  },
  {
    "path": "src/ES.SFTP/appsettings.json",
    "content": "{\r\n  \"AllowedHosts\": \"*\"\r\n}"
  },
  {
    "path": "src/ES.SFTP/config/sftp.json",
    "content": "{\r\n  \"Global\": {\r\n    \"Chroot\": {\r\n      \"Directory\": \"%h\",\r\n      \"StartPath\": \"sftp\"\r\n    },\r\n    \"Directories\": [\"sftp\"],\r\n    \"Logging\": {\r\n      \"IgnoreNoIdentificationString\": true\r\n    },\r\n    \"Hooks\": {\r\n      \"OnServerStartup\": [],\r\n      \"OnSessionChange\": []\r\n    }\r\n  },\r\n  \"Users\": [\r\n    {\r\n      \"Username\": \"demo\",\r\n      \"Password\": \"demo\"\r\n    }\r\n  ],\r\n  \"Groups\": [\r\n    {\r\n      \"Name\": \"demogroup\",\r\n      \"Users\": [\"demo\"],\r\n      \"GID\": 5000\r\n    }\r\n  ]\r\n}"
  },
  {
    "path": "src/ES.SFTP/config/sssd.conf",
    "content": "[sssd]\nconfig_file_version = 2\nservices = nss, pam\ndebug_level = 5\n\n[pam]\n\n[nss]\nfallback_homedir = /home/%u\ndefault_shell = /usr/sbin/nologin\n"
  },
  {
    "path": "src/ES.SFTP.sln",
    "content": "﻿\r\nMicrosoft Visual Studio Solution File, Format Version 12.00\r\n# Visual Studio Version 17\r\nVisualStudioVersion = 17.0.31710.8\r\nMinimumVisualStudioVersion = 10.0.40219.1\r\nProject(\"{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}\") = \"ES.SFTP\", \"ES.SFTP\\ES.SFTP.csproj\", \"{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}\"\r\nEndProject\r\nGlobal\r\n\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\r\n\t\tDebug|Any CPU = Debug|Any CPU\r\n\t\tRelease|Any CPU = Release|Any CPU\r\n\tEndGlobalSection\r\n\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\r\n\t\t{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\r\n\t\t{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU\r\n\t\t{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU\r\n\t\t{AA23AF3B-923C-4E65-BC4C-4E5663E29B1F}.Release|Any CPU.Build.0 = Release|Any CPU\r\n\tEndGlobalSection\r\n\tGlobalSection(SolutionProperties) = preSolution\r\n\t\tHideSolutionNode = FALSE\r\n\tEndGlobalSection\r\n\tGlobalSection(ExtensibilityGlobals) = postSolution\r\n\t\tSolutionGuid = {A4012BFD-4BA0-416A-BFC2-4F7DEF32362B}\r\n\tEndGlobalSection\r\nEndGlobal\r\n"
  },
  {
    "path": "src/ES.SFTP.sln.DotSettings",
    "content": "﻿<wpf:ResourceDictionary xml:space=\"preserve\" xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\" xmlns:s=\"clr-namespace:System;assembly=mscorlib\" xmlns:ss=\"urn:shemas-jetbrains-com:settings-storage-xaml\" xmlns:wpf=\"http://schemas.microsoft.com/winfx/2006/xaml/presentation\">\n\t<s:String x:Key=\"/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SFTP/@EntryIndexedValue\">SFTP</s:String>\n\t<s:String x:Key=\"/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SSH/@EntryIndexedValue\">SSH</s:String>\n\t<s:String x:Key=\"/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SSHD/@EntryIndexedValue\">SSHD</s:String>\n\t<s:String x:Key=\"/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SSSD/@EntryIndexedValue\">SSSD</s:String>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=chown/@EntryIndexedValue\">True</s:Boolean>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=Chroot/@EntryIndexedValue\">True</s:Boolean>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=killall/@EntryIndexedValue\">True</s:Boolean>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=l_0022_002C_0020_0022_002Dq/@EntryIndexedValue\">True</s:Boolean>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=sshd/@EntryIndexedValue\">True</s:Boolean>\n\t<s:Boolean x:Key=\"/Default/UserDictionary/Words/=sssd/@EntryIndexedValue\">True</s:Boolean></wpf:ResourceDictionary>"
  },
  {
    "path": "src/docker-compose/docker-compose.override.dev.yaml",
    "content": "version: '3'\r\nservices:\r\n  sftp:\r\n    image: \"emberstack/sftp:dev\"\r\n    build:\r\n      context: ../\r\n      dockerfile: ES.SFTP.Host/Dockerfile\r\n    ports:\r\n      - \"2222:22\"\r\n    volumes:\r\n    - ../samples/sample.dev.sftp.json:/app/config/sftp.json:ro\r\n    - ../samples/.ssh/id_demo2_rsa.pub:/home/demo2/.ssh/keys/id_rsa.pub:ro\r\n    - ../samples/.ssh/id_demo2_ed25519.pub:/home/demo2/.ssh/keys/id_ed25519.pub:ro\r\n"
  },
  {
    "path": "src/docker-compose/docker-compose.yaml",
    "content": "version: '3'\r\nservices:\r\n  sftp:\r\n    image: \"emberstack/sftp\"\r\n    ports:\r\n      - \"22:22\"\r\n    volumes:\r\n      - ../samples/sample.sftp.json:/app/config/sftp.json:ro"
  },
  {
    "path": "src/helm/sftp/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "src/helm/sftp/Chart.yaml",
    "content": "apiVersion: v2\nname: sftp\ndescription: A Helm chart to deploy SFTP\ntype: application\nversion: 0.1.0\nappVersion: 0.1.0\n\nicon: https://raw.githubusercontent.com/emberstack/CDN/main/projects/docker-sftp/openssh.png\nkeywords:\n- sftp\n- openssh\n- files\n- storage\n- ftp\nhome: https://github.com/EmberStack/docker-sftp\nsources:\n- https://github.com/EmberStack/docker-sftp\nmaintainers:\n- name: winromulus\n  email: helm-charts@emberstack.com\n"
  },
  {
    "path": "src/helm/sftp/LICENSE",
    "content": "MIT License\r\n\r\nCopyright (c) 2019 emberstack\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE."
  },
  {
    "path": "src/helm/sftp/templates/NOTES.txt",
    "content": "SFTP can now be used for secure file transfers. You can connect to your service using any SFTP client."
  },
  {
    "path": "src/helm/sftp/templates/_helpers.tpl",
    "content": "{{/* vim: set filetype=mustache: */}}\n{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"sftp.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"sftp.fullname\" -}}\n{{- if .Values.fullnameOverride -}}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" -}}\n{{- else -}}\n{{- $name := default .Chart.Name .Values.nameOverride -}}\n{{- if contains $name .Release.Name -}}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" -}}\n{{- else -}}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n{{- end -}}\n{{- end -}}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"sftp.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" -}}\n{{- end -}}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"sftp.labels\" -}}\nhelm.sh/chart: {{ include \"sftp.chart\" . }}\n{{ include \"sftp.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end -}}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"sftp.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"sftp.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end -}}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"sftp.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create -}}\n    {{ default (include \"sftp.fullname\" .) .Values.serviceAccount.name }}\n{{- else -}}\n    {{ default \"default\" .Values.serviceAccount.name }}\n{{- end -}}\n{{- end -}}\n"
  },
  {
    "path": "src/helm/sftp/templates/config-secret.yaml",
    "content": "{{- if .Values.configuration }}\napiVersion: v1\nkind: Secret\nmetadata:\n  name: {{ template \"sftp.fullname\" . }}\n  labels:\n    {{- include \"sftp.labels\" . | nindent 4 }}\ntype: Opaque\nstringData:\n  sftp.json: |-\n{{ .Values.configuration | toPrettyJson | nindent 4 }}\n{{- end }}\n  "
  },
  {
    "path": "src/helm/sftp/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"sftp.fullname\" . }}\n  labels:\n    {{- include \"sftp.labels\" . | nindent 4 }}\nspec:\n  replicas: {{ .Values.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"sftp.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      labels:\n        {{- include \"sftp.selectorLabels\" . | nindent 8 }}\n    spec:\n    {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n      serviceAccountName: {{ include \"sftp.serviceAccountName\" . }}\n      securityContext:\n        {{- toYaml .Values.podSecurityContext | nindent 8 }}\n      {{- with .Values.initContainers }}\n      initContainers:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      containers:\n        - name: {{ .Chart.Name }}\n          securityContext:\n            {{- toYaml .Values.securityContext | nindent 12 }}\n          {{- if .Values.image.tag }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n          {{- else }}\n          image: \"{{ .Values.image.repository }}:{{ .Chart.AppVersion }}\"\n          {{- end }}\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          ports:\n            - name: ssh\n              containerPort: 22\n              protocol: TCP\n          {{- if or .Values.configuration .Values.storage.volumeMounts }}\n          volumeMounts:\n          {{- if .Values.configuration }}\n            - name: sftp-json\n              mountPath: \"/app/config/sftp.json\"\n              subPath: sftp.json\n              readOnly: true\n          {{- end }}\n          {{- with .Values.storage.volumeMounts }}\n          {{- toYaml . | nindent 12 }}\n          {{- end }}\n          {{- end }}    \n          livenessProbe:\n            tcpSocket:\n              port: ssh\n          readinessProbe:\n            tcpSocket:\n              port: ssh\n          resources:\n            {{- toYaml .Values.resources | nindent 12 }}\n      {{- if or .Values.configuration .Values.storage.volumes }}\n      volumes:\n      {{- if .Values.configuration }}\n        - name: sftp-json\n          secret:\n            secretName: {{ include \"sftp.fullname\" . }}\n            items:\n              - key: sftp.json\n                path: sftp.json\n      {{- end }}\n      {{- with .Values.storage.volumes }}\n      {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- end }}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n    {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n    {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n    {{- end }}\n"
  },
  {
    "path": "src/helm/sftp/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"sftp.fullname\" . }}\n  labels:\n    {{- include \"sftp.labels\" . | nindent 4 }}\n  {{- with .Values.service.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n{{- if ne .Values.service.type \"NodePort\" }}\n{{- if .Values.service.clusterIP }}\n  clusterIP: \"{{ .Values.service.clusterIP }}\"\n{{- end }}\n{{- end }}\n{{- if .Values.service.externalIPs }}\n  externalIPs:\n{{ toYaml .Values.service.externalIPs | indent 4 }}\n{{- end }}\n{{- if .Values.service.loadBalancerIP }}\n  loadBalancerIP: \"{{ .Values.service.loadBalancerIP }}\"\n{{- end }}\n{{- if .Values.service.loadBalancerSourceRanges }}\n  loadBalancerSourceRanges:\n{{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }}\n{{- end }}\n{{- if and (semverCompare \">=1.7-0\" .Capabilities.KubeVersion.GitVersion) (.Values.service.externalTrafficPolicy) }}\n  externalTrafficPolicy: \"{{ .Values.service.externalTrafficPolicy }}\"\n{{- end }}\n  type: {{ .Values.service.type }}\n  ports:\n    - port: {{ .Values.service.port }}\n      targetPort: ssh\n      protocol: TCP\n      name: ssh\n  selector:\n    {{- include \"sftp.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "src/helm/sftp/templates/serviceaccount.yaml",
    "content": "{{- if .Values.serviceAccount.create -}}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"sftp.serviceAccountName\" . }}\n  labels:\n    {{- include \"sftp.labels\" . | nindent 4 }}\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- end -}}\n"
  },
  {
    "path": "src/helm/sftp/values.yaml",
    "content": "# Default values for sftp.\n# This is a YAML-formatted file.\n# Declare variables to be passed into your templates.\n\nreplicaCount: 1\n\nimage:\n  repository: emberstack/sftp\n  tag: \"\"\n  pullPolicy: Always\n\nimagePullSecrets: []\nnameOverride: \"\"\nfullnameOverride: \"\"\n\nconfiguration: null\n\nserviceAccount:\n  # Specifies whether a service account should be created\n  create: true\n  # Annotations to add to the service account\n  annotations: {}\n  # The name of the service account to use.\n  # If not set and create is true, a name is generated using the fullname template\n  name:\n\npodSecurityContext: {}\n  # fsGroup: 2000\n\nsecurityContext: {}\n  # capabilities:\n  #   drop:\n  #   - ALL\n  # readOnlyRootFilesystem: true\n  # runAsNonRoot: true\n  # runAsUser: 1000\nstorage:\n  volumeMounts: []\n  volumes: []\n\ninitContainers: []\n\nservice:\n  type: ClusterIP\n  port: 22\n\nresources: {}\n  # We usually recommend not to specify default resources and to leave this as a conscious\n  # choice for the user. This also increases chances charts run on environments with little\n  # resources, such as Minikube. If you do want to specify resources, uncomment the following\n  # lines, adjust them as necessary, and remove the curly braces after 'resources:'.\n  # limits:\n  #   cpu: 100m\n  #   memory: 128Mi\n  # requests:\n  #   cpu: 100m\n  #   memory: 128Mi\n\nnodeSelector: {}\n\ntolerations: []\n\naffinity: {}\n"
  }
]