Compare commits

..

No commits in common. "main" and "0.9.6" have entirely different histories.

602 changed files with 34646 additions and 221029 deletions

View file

@ -1,31 +0,0 @@
freebsd_task:
name: FreeBSD
matrix:
- name: FreeBSD 14.3
freebsd_instance:
image_family: freebsd-14-3
pkginstall_script:
- pkg update -f
- pkg install -y go125
- pkg install -y git
setup_script:
- ln -s /usr/local/bin/go125 /usr/local/bin/go
- pw groupadd sftpgo
- pw useradd sftpgo -g sftpgo -w none -m
- mkdir /home/sftpgo/sftpgo
- cp -R . /home/sftpgo/sftpgo
- chown -R sftpgo:sftpgo /home/sftpgo/sftpgo
compile_script:
- su sftpgo -c 'cd ~/sftpgo && go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo'
- su sftpgo -c 'cd ~/sftpgo/tests/eventsearcher && go build -trimpath -ldflags "-s -w" -o eventsearcher'
- su sftpgo -c 'cd ~/sftpgo/tests/ipfilter && go build -trimpath -ldflags "-s -w" -o ipfilter'
check_script:
- su sftpgo -c 'cd ~/sftpgo && ./sftpgo initprovider && ./sftpgo resetprovider --force'
test_script:
- su sftpgo -c 'cd ~/sftpgo && go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 20m ./... -coverprofile=coverage.txt -covermode=atomic'

12
.github/FUNDING.yml vendored
View file

@ -1,12 +0,0 @@
# These are supported funding model platforms
github: [drakkan] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

@ -1,110 +0,0 @@
name: Open Source Bug Report
description: "Submit a report and help us improve SFTPGo"
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
### 👍 Thank you for contributing to our project!
Before asking for help please check our [support policy](https://github.com/drakkan/sftpgo?tab=readme-ov-file#support).
If you are a [commercial user](https://sftpgo.com/) please contact us using the dedicated [email address](mailto:support@sftpgo.com).
If you'd like to contribute code, please make sure to read and understand our [Contributor License Agreement (CLA)](https://sftpgo.com/cla.html).
Youll be asked to accept it when submitting a pull request.
- type: checkboxes
id: before-posting
attributes:
label: "⚠️ This issue respects the following points: ⚠️"
description: All conditions are **required**.
options:
- label: This is a **bug**, not a question or a configuration issue.
required: true
- label: This issue is **not** already reported on Github _(I've searched it)_.
required: true
- type: textarea
id: bug-description
attributes:
label: Bug description
description: |
Provide a description of the bug you're experiencing.
Don't just expect someone will guess what your specific problem is and provide full details.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: |
Describe the steps to reproduce the bug.
The better your description is the fastest you'll get an _(accurate)_ answer.
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: Describe what you expected to happen instead.
validations:
required: true
- type: input
id: version
attributes:
label: SFTPGo version
validations:
required: true
- type: input
id: data-provider
attributes:
label: Data provider
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Installation method
description: |
Select installation method you've used.
_Describe the method in the "Additional info" section if you chose "Other"._
options:
- "Community Docker image"
- "Community Deb package"
- "Community RPM package"
- "Other"
validations:
required: true
- type: textarea
attributes:
label: Configuration
description: "Describe your customizations to the configuration: both config file changes and overrides via environment variables"
value: config
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: dropdown
id: usecase
attributes:
label: What are you using SFTPGo for?
description: We'd like to understand your SFTPGo usecase more
multiple: true
options:
- "Private user, home usecase (home backup/VPS)"
- "Professional user, 1 person business"
- "Small business (3-person firm with file exchange?)"
- "Medium business"
- "Enterprise"
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional info
description: Any additional information related to the issue.

View file

@ -1,9 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Commercial Support
url: https://sftpgo.com/
about: >
If you need Professional support, so your reports are prioritized and resolved more quickly.
- name: GitHub Community Discussions
url: https://github.com/drakkan/sftpgo/discussions
about: Please ask and answer questions here.

View file

@ -1,50 +0,0 @@
name: 🚀 Feature request
description: Suggest an idea for SFTPGo
labels: ["suggestion"]
body:
- type: markdown
attributes:
value: |
### 👍 Thank you for contributing to our project!
Before asking for help please check our [support policy](https://github.com/drakkan/sftpgo?tab=readme-ov-file#support).
If you are a [commercial user](https://sftpgo.com/) please contact us using the dedicated [email address](mailto:support@sftpgo.com).
If you'd like to contribute code, please make sure to read and understand our [Contributor License Agreement (CLA)](https://sftpgo.com/cla.html).
Youll be asked to accept it when submitting a pull request.
- type: textarea
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is.
validations:
required: false
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: dropdown
id: usecase
attributes:
label: What are you using SFTPGo for?
description: We'd like to understand your SFTPGo usecase more
multiple: true
options:
- "Private user, home usecase (home backup/VPS)"
- "Professional user, 1 person business"
- "Small business (3-person firm with file exchange?)"
- "Medium business"
- "Enterprise"
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

View file

@ -1,5 +0,0 @@
# Checklist for Pull Requests
- [ ] Have you signed the [Contributor License Agreement](https://sftpgo.com/cla.html)?
---

View file

@ -1,20 +0,0 @@
version: 2
updates:
#- package-ecosystem: "gomod"
# directory: "/"
# schedule:
# interval: "weekly"
# open-pull-requests-limit: 2
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 2
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 2

View file

@ -1,2 +0,0 @@
[*.yml]
indent_size = 2

View file

@ -1,36 +0,0 @@
name: "Code scanning - action"
on:
push:
pull_request:
schedule:
- cron: '30 1 * * 6'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: go
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4

View file

@ -1,562 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
id-token: write
contents: read
jobs:
test-deploy:
name: Test and deploy
runs-on: ${{ matrix.os }}
strategy:
matrix:
go: ['1.26']
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}
- name: Build for Linux/macOS x86_64
run: |
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
cd tests/eventsearcher
go build -trimpath -ldflags "-s -w" -o eventsearcher
cd -
cd tests/ipfilter
go build -trimpath -ldflags "-s -w" -o ipfilter
cd -
./sftpgo initprovider
./sftpgo resetprovider --force
- name: Build for macOS arm64
if: startsWith(matrix.os, 'macos-') == true
run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
- name: Run test cases using SQLite provider
run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage.txt
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
- name: Run test cases using bolt provider
run: |
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/config -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/common -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/httpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 8m ./internal/sftpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/ftpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/webdavd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/telemetry -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/mfa -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/command -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: bolt
SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
- name: Run test cases using memory provider
run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: memory
SFTPGO_DATA_PROVIDER__NAME: ''
- name: Prepare build artifact for macOS
if: startsWith(matrix.os, 'macos-') == true
run: |
mkdir -p output/{init,bash_completion,zsh_completion}
cp sftpgo output/sftpgo_x86_64
cp sftpgo_arm64 output/
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
cp -r openapi output/
cp init/com.github.drakkan.sftpgo.plist output/init/
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
- name: Upload build artifact
if: startsWith(matrix.os, 'ubuntu-') != true
uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ matrix.os }}-go-${{ matrix.go }}
path: output
test-deploy-windows:
name: Test and deploy Windows
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: Run test cases using SQLite provider
run: |
cd tests/eventsearcher
go build -trimpath -ldflags "-s -w" -o eventsearcher.exe
cd ../..
cd tests/ipfilter
go build -trimpath -ldflags "-s -w" -o ipfilter.exe
cd ../..
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
- name: Run test cases using bolt provider
run: |
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/config -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/common -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/httpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 8m ./internal/sftpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/ftpd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 5m ./internal/webdavd -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/telemetry -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/mfa -covermode=atomic
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 2m ./internal/command -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: bolt
SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
- name: Run test cases using memory provider
run: go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: memory
SFTPGO_DATA_PROVIDER__NAME: ''
- name: Build
run: |
$GIT_COMMIT = (git describe --always --abbrev=8 --dirty) | Out-String
$DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
$LATEST_TAG = ((git describe --tags $(git rev-list --tags --max-count=1)) | Out-String).Trim()
$REV_LIST=$LATEST_TAG+"..HEAD"
$COMMITS_FROM_TAG= ((git rev-list $REV_LIST --count) | Out-String).Trim()
$FILE_VERSION = $LATEST_TAG.substring(1) + "." + $COMMITS_FROM_TAG
go install github.com/tc-hib/go-winres@latest
go-winres simply --arch amd64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
mkdir arm64
$Env:CGO_ENABLED='0'
$Env:GOOS='windows'
$Env:GOARCH='arm64'
go-winres simply --arch arm64 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
mkdir x86
$Env:GOARCH='386'
go-winres simply --arch 386 --product-version $LATEST_TAG-dev-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
Remove-Item Env:\CGO_ENABLED
Remove-Item Env:\GOOS
Remove-Item Env:\GOARCH
- name: Initialize data provider
run: |
rm sftpgo.db
./sftpgo initprovider
shell: bash
- name: Prepare Windows installers
if: ${{ github.event_name != 'pull_request' }}
run: |
choco install innosetup
Remove-Item -LiteralPath "output" -Force -Recurse -ErrorAction Ignore
mkdir output
copy .\sftpgo.exe .\output
copy .\sftpgo.json .\output
copy .\sftpgo.db .\output
copy .\LICENSE .\output\LICENSE.txt
copy .\NOTICE .\output\NOTICE.txt
mkdir output\templates
xcopy .\templates .\output\templates\ /E
mkdir output\static
xcopy .\static .\output\static\ /E
mkdir output\openapi
xcopy .\openapi .\output\openapi\ /E
$LATEST_TAG = ((git describe --tags $(git rev-list --tags --max-count=1)) | Out-String).Trim()
$REV_LIST=$LATEST_TAG+"..HEAD"
$COMMITS_FROM_TAG= ((git rev-list $REV_LIST --count) | Out-String).Trim()
$Env:SFTPGO_ISS_DEV_VERSION = $LATEST_TAG + "." + $COMMITS_FROM_TAG
iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
rm .\output\sftpgo.db
copy .\arm64\sftpgo.exe .\output
(Get-Content .\output\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\output\sftpgo.json
$Env:SFTPGO_DATA_PROVIDER__DRIVER='bolt'
$Env:SFTPGO_DATA_PROVIDER__NAME='.\output\sftpgo.db'
.\sftpgo.exe initprovider
Remove-Item Env:\SFTPGO_DATA_PROVIDER__DRIVER
Remove-Item Env:\SFTPGO_DATA_PROVIDER__NAME
$Env:SFTPGO_ISS_ARCH='arm64'
iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
copy .\x86\sftpgo.exe .\output
$Env:SFTPGO_ISS_ARCH='x86'
iscc .\windows-installer\sftpgo.iss
- name: Upload Windows installer x86_64 artifact
if: ${{ github.event_name != 'pull_request' }}
uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_x86_64
path: ./sftpgo_windows_x86_64.exe
- name: Upload Windows installer arm64 artifact
if: ${{ github.event_name != 'pull_request' }}
uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_arm64
path: ./sftpgo_windows_arm64.exe
- name: Upload Windows installer x86 artifact
if: ${{ github.event_name != 'pull_request' }}
uses: actions/upload-artifact@v7
with:
name: sftpgo_windows_installer_x86
path: ./sftpgo_windows_x86.exe
- name: Prepare build artifact for Windows
run: |
Remove-Item -LiteralPath "output" -Force -Recurse -ErrorAction Ignore
mkdir output
copy .\sftpgo.exe .\output
mkdir output\arm64
copy .\arm64\sftpgo.exe .\output\arm64
mkdir output\x86
copy .\x86\sftpgo.exe .\output\x86
copy .\sftpgo.json .\output
(Get-Content .\output\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\output\sftpgo.json
mkdir output\templates
xcopy .\templates .\output\templates\ /E
mkdir output\static
xcopy .\static .\output\static\ /E
mkdir output\openapi
xcopy .\openapi .\output\openapi\ /E
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo-windows-portable
path: output
test-build-flags:
name: Test build flags
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: Build
run: |
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nogcs,nos3,noportable,nobolt,nomysql,nopgsql,nosqlite,nometrics,noazblob,unixcrypt -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
./sftpgo -v
cp -r openapi static templates internal/bundle/
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,bundle -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
./sftpgo -v
test-postgresql-mysql-crdb:
name: Test with PgSQL/MySQL/Cockroach
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sftpgo
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mariadb:
image: mariadb:latest
env:
MYSQL_ROOT_PASSWORD: mysql
MYSQL_DATABASE: sftpgo
MYSQL_USER: sftpgo
MYSQL_PASSWORD: sftpgo
options: >-
--health-cmd "mariadb-admin status -h 127.0.0.1 -P 3306 -u root -p$MYSQL_ROOT_PASSWORD"
--health-interval 10s
--health-timeout 5s
--health-retries 6
ports:
- 3307:3306
mysql:
image: mysql:latest
env:
MYSQL_ROOT_PASSWORD: mysql
MYSQL_DATABASE: sftpgo
MYSQL_USER: sftpgo
MYSQL_PASSWORD: sftpgo
options: >-
--health-cmd "mysqladmin status -h 127.0.0.1 -P 3306 -u root -p$MYSQL_ROOT_PASSWORD"
--health-interval 10s
--health-timeout 5s
--health-retries 6
ports:
- 3308:3306
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: Build
run: |
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
cd tests/eventsearcher
go build -trimpath -ldflags "-s -w" -o eventsearcher
cd -
cd tests/ipfilter
go build -trimpath -ldflags "-s -w" -o ipfilter
cd -
- name: Run tests using MySQL provider
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: mysql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
SFTPGO_DATA_PROVIDER__HOST: localhost
SFTPGO_DATA_PROVIDER__PORT: 3308
SFTPGO_DATA_PROVIDER__USERNAME: sftpgo
SFTPGO_DATA_PROVIDER__PASSWORD: sftpgo
- name: Run tests using PostgreSQL provider
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: postgresql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
SFTPGO_DATA_PROVIDER__HOST: localhost
SFTPGO_DATA_PROVIDER__PORT: 5432
SFTPGO_DATA_PROVIDER__USERNAME: postgres
SFTPGO_DATA_PROVIDER__PASSWORD: postgres
- name: Run tests using MariaDB provider
run: |
./sftpgo initprovider
./sftpgo resetprovider --force
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
env:
SFTPGO_DATA_PROVIDER__DRIVER: mysql
SFTPGO_DATA_PROVIDER__NAME: sftpgo
SFTPGO_DATA_PROVIDER__HOST: localhost
SFTPGO_DATA_PROVIDER__PORT: 3307
SFTPGO_DATA_PROVIDER__USERNAME: sftpgo
SFTPGO_DATA_PROVIDER__PASSWORD: sftpgo
SFTPGO_DATA_PROVIDER__SQL_TABLES_PREFIX: prefix_
- name: Run tests using CockroachDB provider
run: |
docker run --rm --name crdb --health-cmd "curl -I http://127.0.0.1:8080" --health-interval 10s --health-timeout 5s --health-retries 6 -p 26257:26257 -d cockroachdb/cockroach:latest start-single-node --insecure --listen-addr :26257
sleep 10
docker exec crdb cockroach sql --insecure -e 'create database "sftpgo"'
./sftpgo initprovider
./sftpgo resetprovider --force
go test -v -tags nopgxregisterdefaulttypes,disable_grpc_modules -p 1 -timeout 15m ./... -covermode=atomic
docker stop crdb
env:
SFTPGO_DATA_PROVIDER__DRIVER: cockroachdb
SFTPGO_DATA_PROVIDER__NAME: sftpgo
SFTPGO_DATA_PROVIDER__HOST: localhost
SFTPGO_DATA_PROVIDER__PORT: 26257
SFTPGO_DATA_PROVIDER__USERNAME: root
SFTPGO_DATA_PROVIDER__PASSWORD:
SFTPGO_DATA_PROVIDER__TARGET_SESSION_ATTRS: any
SFTPGO_DATA_PROVIDER__SQL_TABLES_PREFIX: prefix_
build-linux-packages:
name: Build Linux packages
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: amd64
distro: ubuntu:18.04
go: latest
go-arch: amd64
- arch: aarch64
distro: ubuntu18.04
go: latest
go-arch: arm64
- arch: ppc64le
distro: ubuntu18.04
go: latest
go-arch: ppc64le
- arch: armv7
distro: ubuntu18.04
go: latest
go-arch: arm7
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get commit SHA
id: get_commit
run: echo "COMMIT=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
shell: bash
- name: Build on amd64
if: ${{ matrix.arch == 'amd64' }}
run: |
echo '#!/bin/bash' > build.sh
echo '' >> build.sh
echo 'set -e' >> build.sh
echo 'apt-get update -q -y' >> build.sh
echo 'apt-get install -q -y curl gcc' >> build.sh
if [ ${{ matrix.go }} == 'latest' ]
then
echo 'GO_VERSION=$(curl -L https://go.dev/VERSION?m=text | head -n 1)' >> build.sh
else
echo 'GO_VERSION=${{ matrix.go }}' >> build.sh
fi
echo 'GO_DOWNLOAD_ARCH=${{ matrix.go-arch }}' >> build.sh
echo 'curl --retry 5 --retry-delay 2 --connect-timeout 10 -o go.tar.gz -L https://go.dev/dl/${GO_VERSION}.linux-${GO_DOWNLOAD_ARCH}.tar.gz' >> build.sh
echo 'tar -C /usr/local -xzf go.tar.gz' >> build.sh
echo 'export PATH=$PATH:/usr/local/go/bin' >> build.sh
echo 'go version' >> build.sh
echo 'cd /usr/local/src' >> build.sh
echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
chmod 755 build.sh
docker run --rm --name ubuntu-build --mount type=bind,source=`pwd`,target=/usr/local/src ${{ matrix.distro }} /usr/local/src/build.sh
mkdir -p output/{init,bash_completion,zsh_completion}
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
cp -r openapi output/
cp init/sftpgo.service output/init/
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cp sftpgo output/
- uses: uraimo/run-on-arch-action@v3
if: ${{ matrix.arch != 'amd64' }}
name: Build for ${{ matrix.arch }}
id: build
with:
arch: ${{ matrix.arch }}
distro: ${{ matrix.distro }}
setup: |
mkdir -p "${PWD}/output"
dockerRunArgs: |
--volume "${PWD}/output:/output"
shell: /bin/bash
install: |
apt-get update -q -y
apt-get install -q -y curl gcc
if [ ${{ matrix.go }} == 'latest' ]
then
GO_VERSION=$(curl -L https://go.dev/VERSION?m=text | head -n 1)
else
GO_VERSION=${{ matrix.go }}
fi
GO_DOWNLOAD_ARCH=${{ matrix.go-arch }}
if [ ${{ matrix.arch}} == 'armv7' ]
then
GO_DOWNLOAD_ARCH=armv6l
fi
curl --retry 5 --retry-delay 2 --connect-timeout 10 -o go.tar.gz -L https://go.dev/dl/${GO_VERSION}.linux-${GO_DOWNLOAD_ARCH}.tar.gz
tar -C /usr/local -xzf go.tar.gz
run: |
export PATH=$PATH:/usr/local/go/bin
go version
if [ ${{ matrix.arch}} == 'armv7' ]
then
export GOARM=7
fi
go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_commit.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
mkdir -p output/{init,bash_completion,zsh_completion}
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
cp -r openapi output/
cp init/sftpgo.service output/init/
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cp sftpgo output/
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo-linux-${{ matrix.arch }}-go-${{ matrix.go }}
path: output
- name: Build Packages
id: build_linux_pkgs
run: |
export NFPM_ARCH=${{ matrix.go-arch }}
cd pkgs
./build.sh
PKG_VERSION=$(cat dist/version)
echo "pkg-version=${PKG_VERSION}" >> $GITHUB_OUTPUT
- name: Upload Debian Package
uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-${{ matrix.go-arch }}-deb
path: pkgs/dist/deb/*
- name: Upload RPM Package
uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-${{ matrix.go-arch }}-rpm
path: pkgs/dist/rpm/*
golangci-lint:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26'
- uses: actions/checkout@v6
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest

View file

@ -1,188 +0,0 @@
name: Docker
on:
#schedule:
# - cron: '0 4 * * *' # everyday at 4:00 AM UTC
push:
branches:
- main
tags:
- v*
pull_request:
jobs:
build:
name: Build
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
docker_pkg:
- debian
- alpine
optional_deps:
- true
- false
include:
- os: ubuntu-latest
docker_pkg: distroless
optional_deps: false
- os: ubuntu-latest
docker_pkg: debian-plugins
optional_deps: true
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Gather image information
id: info
run: |
VERSION=noop
DOCKERFILE=Dockerfile
MINOR=""
MAJOR=""
FEATURES="nopgxregisterdefaulttypes,disable_grpc_modules"
if [ "${{ github.event_name }}" = "schedule" ]; then
VERSION=nightly
elif [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
elif [[ $GITHUB_REF == refs/heads/* ]]; then
VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g')
if [ "${{ github.event.repository.default_branch }}" = "$VERSION" ]; then
VERSION=edge
fi
elif [[ $GITHUB_REF == refs/pull/* ]]; then
VERSION=pr-${{ github.event.number }}
fi
if [[ $VERSION =~ ^v[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
MINOR=${VERSION%.*}
MAJOR=${MINOR%.*}
fi
VERSION_SLIM="${VERSION}-slim"
if [[ $DOCKER_PKG == alpine ]]; then
VERSION="${VERSION}-alpine"
VERSION_SLIM="${VERSION}-slim"
DOCKERFILE=Dockerfile.alpine
elif [[ $DOCKER_PKG == distroless ]]; then
VERSION="${VERSION}-distroless"
VERSION_SLIM="${VERSION}-slim"
DOCKERFILE=Dockerfile.distroless
FEATURES="${FEATURES},nosqlite"
elif [[ $DOCKER_PKG == debian-plugins ]]; then
VERSION="${VERSION}-plugins"
VERSION_SLIM="${VERSION}-slim"
FEATURES="${FEATURES},unixcrypt"
elif [[ $DOCKER_PKG == debian ]]; then
FEATURES="${FEATURES},unixcrypt"
fi
DOCKER_IMAGES=("drakkan/sftpgo" "ghcr.io/drakkan/sftpgo")
TAGS="${DOCKER_IMAGES[0]}:${VERSION}"
TAGS_SLIM="${DOCKER_IMAGES[0]}:${VERSION_SLIM}"
for DOCKER_IMAGE in ${DOCKER_IMAGES[@]}; do
if [[ ${DOCKER_IMAGE} != ${DOCKER_IMAGES[0]} ]]; then
TAGS="${TAGS},${DOCKER_IMAGE}:${VERSION}"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${VERSION_SLIM}"
fi
if [[ $GITHUB_REF == refs/tags/* ]]; then
if [[ $DOCKER_PKG == debian ]]; then
if [[ -n $MAJOR && -n $MINOR ]]; then
TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR},${DOCKER_IMAGE}:${MAJOR}"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${MINOR}-slim,${DOCKER_IMAGE}:${MAJOR}-slim"
fi
TAGS="${TAGS},${DOCKER_IMAGE}:latest"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:slim"
elif [[ $DOCKER_PKG == distroless ]]; then
if [[ -n $MAJOR && -n $MINOR ]]; then
TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-distroless,${DOCKER_IMAGE}:${MAJOR}-distroless"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${MINOR}-distroless-slim,${DOCKER_IMAGE}:${MAJOR}-distroless-slim"
fi
TAGS="${TAGS},${DOCKER_IMAGE}:distroless"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:distroless-slim"
elif [[ $DOCKER_PKG == debian-plugins ]]; then
if [[ -n $MAJOR && -n $MINOR ]]; then
TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-plugins,${DOCKER_IMAGE}:${MAJOR}-plugins"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${MINOR}-plugins-slim,${DOCKER_IMAGE}:${MAJOR}-plugins-slim"
fi
TAGS="${TAGS},${DOCKER_IMAGE}:plugins"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:plugins-slim"
else
if [[ -n $MAJOR && -n $MINOR ]]; then
TAGS="${TAGS},${DOCKER_IMAGE}:${MINOR}-alpine,${DOCKER_IMAGE}:${MAJOR}-alpine"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:${MINOR}-alpine-slim,${DOCKER_IMAGE}:${MAJOR}-alpine-slim"
fi
TAGS="${TAGS},${DOCKER_IMAGE}:alpine"
TAGS_SLIM="${TAGS_SLIM},${DOCKER_IMAGE}:alpine-slim"
fi
fi
done
if [[ $OPTIONAL_DEPS == true ]]; then
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "full=true" >> $GITHUB_OUTPUT
else
echo "version=${VERSION_SLIM}" >> $GITHUB_OUTPUT
echo "tags=${TAGS_SLIM}" >> $GITHUB_OUTPUT
echo "full=false" >> $GITHUB_OUTPUT
fi
if [[ $DOCKER_PKG == debian-plugins ]]; then
echo "plugins=true" >> $GITHUB_OUTPUT
else
echo "plugins=false" >> $GITHUB_OUTPUT
fi
echo "dockerfile=${DOCKERFILE}" >> $GITHUB_OUTPUT
echo "features=${FEATURES}" >> $GITHUB_OUTPUT
echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
echo "sha=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
env:
DOCKER_PKG: ${{ matrix.docker_pkg }}
OPTIONAL_DEPS: ${{ matrix.optional_deps }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up builder
uses: docker/setup-buildx-action@v4
id: builder
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ github.event_name != 'pull_request' }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ github.event_name != 'pull_request' }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
builder: ${{ steps.builder.outputs.name }}
file: ./${{ steps.info.outputs.dockerfile }}
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.info.outputs.tags }}
build-args: |
COMMIT_SHA=${{ steps.info.outputs.sha }}
INSTALL_OPTIONAL_PACKAGES=${{ steps.info.outputs.full }}
DOWNLOAD_PLUGINS=${{ steps.info.outputs.plugins }}
FEATURES=${{ steps.info.outputs.features }}
labels: |
org.opencontainers.image.title=SFTPGo
org.opencontainers.image.description=Full-featured and highly configurable file transfer server: SFTP, HTTP/S,FTP/S, WebDAV
org.opencontainers.image.url=https://github.com/drakkan/sftpgo
org.opencontainers.image.documentation=https://github.com/drakkan/sftpgo/blob/${{ github.sha }}/docker/README.md
org.opencontainers.image.source=https://github.com/drakkan/sftpgo
org.opencontainers.image.version=${{ steps.info.outputs.version }}
org.opencontainers.image.created=${{ steps.info.outputs.created }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=AGPL-3.0-only

View file

@ -1,594 +0,0 @@
name: Release
on:
push:
tags: 'v*'
permissions:
id-token: write
contents: write
env:
GO_VERSION: 1.25.8
jobs:
prepare-sources-with-deps:
name: Prepare sources with deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Get SFTPGo version
id: get_version
run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
- name: Prepare release
run: |
go mod vendor
echo "${SFTPGO_VERSION}" > VERSION.txt
echo "${GITHUB_SHA::8}" >> VERSION.txt
tar cJvf sftpgo_${SFTPGO_VERSION}_src_with_deps.tar.xz *
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.VERSION }}
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_src_with_deps.tar.xz
path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_src_with_deps.tar.xz
retention-days: 1
prepare-windows:
name: Prepare Windows binaries
runs-on: windows-2022
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Get SFTPGo version
id: get_version
run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
shell: bash
- name: Build
run: |
$GIT_COMMIT = (git describe --always --abbrev=8 --dirty) | Out-String
$DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String
$FILE_VERSION = $Env:SFTPGO_VERSION.substring(1) + ".0"
go install github.com/tc-hib/go-winres@latest
go-winres simply --arch amd64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o sftpgo.exe
mkdir arm64
$Env:CGO_ENABLED='0'
$Env:GOOS='windows'
$Env:GOARCH='arm64'
go-winres simply --arch arm64 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\arm64\sftpgo.exe
mkdir x86
$Env:GOARCH='386'
go-winres simply --arch 386 --product-version $Env:SFTPGO_VERSION-$GIT_COMMIT --file-version "$FILE_VERSION" --file-description "SFTPGo server" --product-name SFTPGo --copyright "2019-2025 Nicola Murino" --original-filename sftpgo.exe --icon .\windows-installer\icon.ico
go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules,nosqlite -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/internal/version.date=$DATE_TIME" -o .\x86\sftpgo.exe
Remove-Item Env:\CGO_ENABLED
Remove-Item Env:\GOOS
Remove-Item Env:\GOARCH
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.VERSION }}
- name: Initialize data provider
run: ./sftpgo initprovider
shell: bash
- name: Prepare Release
run: |
mkdir output
copy .\sftpgo.exe .\output
copy .\sftpgo.json .\output
copy .\sftpgo.db .\output
copy .\LICENSE .\output\LICENSE.txt
copy .\NOTICE .\output\NOTICE.txt
mkdir output\templates
xcopy .\templates .\output\templates\ /E
mkdir output\static
xcopy .\static .\output\static\ /E
mkdir output\openapi
xcopy .\openapi .\output\openapi\ /E
iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
rm .\output\sftpgo.db
copy .\arm64\sftpgo.exe .\output
(Get-Content .\output\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\output\sftpgo.json
$Env:SFTPGO_DATA_PROVIDER__DRIVER='bolt'
$Env:SFTPGO_DATA_PROVIDER__NAME='.\output\sftpgo.db'
.\sftpgo.exe initprovider
Remove-Item Env:\SFTPGO_DATA_PROVIDER__DRIVER
Remove-Item Env:\SFTPGO_DATA_PROVIDER__NAME
$Env:SFTPGO_ISS_ARCH='arm64'
iscc .\windows-installer\sftpgo.iss
rm .\output\sftpgo.exe
copy .\x86\sftpgo.exe .\output
$Env:SFTPGO_ISS_ARCH='x86'
iscc .\windows-installer\sftpgo.iss
env:
SFTPGO_ISS_VERSION: ${{ steps.get_version.outputs.VERSION }}
- name: Prepare Portable Release
run: |
mkdir win-portable
copy .\sftpgo.exe .\win-portable
mkdir win-portable\arm64
copy .\arm64\sftpgo.exe .\win-portable\arm64
mkdir win-portable\x86
copy .\x86\sftpgo.exe .\win-portable\x86
copy .\sftpgo.json .\win-portable
(Get-Content .\win-portable\sftpgo.json).replace('"sqlite"', '"bolt"') | Set-Content .\win-portable\sftpgo.json
copy .\output\sftpgo.db .\win-portable
copy .\LICENSE .\win-portable\LICENSE.txt
copy .\NOTICE .\win-portable\NOTICE.txt
mkdir win-portable\templates
xcopy .\templates .\win-portable\templates\ /E
mkdir win-portable\static
xcopy .\static .\win-portable\static\ /E
mkdir win-portable\openapi
xcopy .\openapi .\win-portable\openapi\ /E
Compress-Archive .\win-portable\* sftpgo_portable.zip
- name: Upload Windows installer x86_64 artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_x86_64.exe
path: ./sftpgo_windows_x86_64.exe
retention-days: 1
- name: Upload Windows installer arm64 artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_arm64.exe
path: ./sftpgo_windows_arm64.exe
retention-days: 1
- name: Upload Windows installer x86 artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_x86.exe
path: ./sftpgo_windows_x86.exe
retention-days: 1
- name: Upload Windows portable artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_windows_portable.zip
path: ./sftpgo_portable.zip
retention-days: 1
prepare-mac:
name: Prepare macOS binaries
runs-on: macos-14
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Get SFTPGo version
id: get_version
run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
shell: bash
- name: Build for macOS x86_64
run: go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
- name: Build for macOS arm64
run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=`git describe --always --abbrev=8 --dirty` -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64
- name: Initialize data provider
run: ./sftpgo initprovider
shell: bash
- name: Prepare Release
run: |
mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
echo "For documentation please take a look here:" > output/README.txt
echo "" >> output/README.txt
echo "https://docs.sftpgo.com" >> output/README.txt
cp LICENSE output/
cp NOTICE output/
cp sftpgo output/
cp sftpgo.json output/
cp sftpgo.db output/sqlite/
cp -r static output/
cp -r openapi output/
cp -r templates output/
cp init/com.github.drakkan.sftpgo.plist output/init/
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cd output
tar cJvf ../sftpgo_${SFTPGO_VERSION}_macOS_x86_64.tar.xz *
cd ..
cp sftpgo_arm64 output/sftpgo
cd output
tar cJvf ../sftpgo_${SFTPGO_VERSION}_macOS_arm64.tar.xz *
cd ..
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.VERSION }}
- name: Upload macOS x86_64 artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_x86_64.tar.xz
path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_x86_64.tar.xz
retention-days: 1
- name: Upload macOS arm64 artifact
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_arm64.tar.xz
path: ./sftpgo_${{ steps.get_version.outputs.VERSION }}_macOS_arm64.tar.xz
retention-days: 1
prepare-linux:
name: Prepare Linux binaries
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: amd64
distro: ubuntu:18.04
go-arch: amd64
deb-arch: amd64
rpm-arch: x86_64
tar-arch: x86_64
- arch: aarch64
distro: ubuntu18.04
go-arch: arm64
deb-arch: arm64
rpm-arch: aarch64
tar-arch: arm64
- arch: ppc64le
distro: ubuntu18.04
go-arch: ppc64le
deb-arch: ppc64el
rpm-arch: ppc64le
tar-arch: ppc64le
- arch: armv7
distro: ubuntu18.04
go-arch: arm7
deb-arch: armhf
rpm-arch: armv7hl
tar-arch: armv7
steps:
- uses: actions/checkout@v6
- name: Get versions
id: get_version
run: |
echo "SFTPGO_VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
echo "GO_VERSION=${GO_VERSION}" >> $GITHUB_OUTPUT
echo "COMMIT=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
shell: bash
env:
GO_VERSION: ${{ env.GO_VERSION }}
- name: Build on amd64
if: ${{ matrix.arch == 'amd64' }}
run: |
echo '#!/bin/bash' > build.sh
echo '' >> build.sh
echo 'set -e' >> build.sh
echo 'apt-get update -q -y' >> build.sh
echo 'apt-get install -q -y curl gcc' >> build.sh
echo 'curl --retry 5 --retry-delay 2 --connect-timeout 10 -o go.tar.gz -L https://go.dev/dl/go${{ steps.get_version.outputs.GO_VERSION }}.linux-${{ matrix.go-arch }}.tar.gz' >> build.sh
echo 'tar -C /usr/local -xzf go.tar.gz' >> build.sh
echo 'export PATH=$PATH:/usr/local/go/bin' >> build.sh
echo 'go version' >> build.sh
echo 'cd /usr/local/src' >> build.sh
echo 'go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo' >> build.sh
chmod 755 build.sh
docker run --rm --name ubuntu-build --mount type=bind,source=`pwd`,target=/usr/local/src ${{ matrix.distro }} /usr/local/src/build.sh
mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
echo "For documentation please take a look here:" > output/README.txt
echo "" >> output/README.txt
echo "https://github.com/drakkan/sftpgo/blob/${SFTPGO_VERSION}/README.md" >> output/README.txt
cp LICENSE output/
cp NOTICE output/
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
cp -r openapi output/
cp init/sftpgo.service output/init/
./sftpgo initprovider
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cp sftpgo output/
cp sftpgo.db output/sqlite/
cd output
tar cJvf sftpgo_${SFTPGO_VERSION}_linux_${{ matrix.tar-arch }}.tar.xz *
cd ..
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- uses: uraimo/run-on-arch-action@v3
if: ${{ matrix.arch != 'amd64' }}
name: Build for ${{ matrix.arch }}
id: build
with:
arch: ${{ matrix.arch }}
distro: ${{ matrix.distro }}
setup: |
mkdir -p "${PWD}/output"
dockerRunArgs: |
--volume "${PWD}/output:/output"
shell: /bin/bash
install: |
apt-get update -q -y
apt-get install -q -y curl gcc xz-utils
GO_DOWNLOAD_ARCH=${{ matrix.go-arch }}
if [ ${{ matrix.arch}} == 'armv7' ]
then
GO_DOWNLOAD_ARCH=armv6l
fi
curl --retry 5 --retry-delay 2 --connect-timeout 10 -o go.tar.gz -L https://go.dev/dl/go${{ steps.get_version.outputs.GO_VERSION }}.linux-${GO_DOWNLOAD_ARCH}.tar.gz
tar -C /usr/local -xzf go.tar.gz
run: |
export PATH=$PATH:/usr/local/go/bin
go version
go build -buildvcs=false -trimpath -tags nopgxregisterdefaulttypes,disable_grpc_modules -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${{ steps.get_version.outputs.COMMIT }} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -o sftpgo
mkdir -p output/{init,sqlite,bash_completion,zsh_completion}
echo "For documentation please take a look here:" > output/README.txt
echo "" >> output/README.txt
echo "https://github.com/drakkan/sftpgo/blob/${{ steps.get_version.outputs.SFTPGO_VERSION }}/README.md" >> output/README.txt
cp LICENSE output/
cp NOTICE output/
cp sftpgo.json output/
cp -r templates output/
cp -r static output/
cp -r openapi output/
cp init/sftpgo.service output/init/
./sftpgo initprovider
./sftpgo gen completion bash > output/bash_completion/sftpgo
./sftpgo gen completion zsh > output/zsh_completion/_sftpgo
./sftpgo gen man -d output/man/man1
gzip output/man/man1/*
cp sftpgo output/
cp sftpgo.db output/sqlite/
cd output
tar cJvf sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_${{ matrix.tar-arch }}.tar.xz *
cd ..
- name: Upload build artifact for ${{ matrix.arch }}
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_${{ matrix.tar-arch }}.tar.xz
path: ./output/sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_${{ matrix.tar-arch }}.tar.xz
retention-days: 1
- name: Build Packages
id: build_linux_pkgs
run: |
export NFPM_ARCH=${{ matrix.go-arch }}
cd pkgs
./build.sh
PKG_VERSION=${SFTPGO_VERSION:1}
echo "pkg-version=${PKG_VERSION}" >> $GITHUB_OUTPUT
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- name: Upload Deb Package
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.build_linux_pkgs.outputs.pkg-version }}-1_${{ matrix.deb-arch}}.deb
path: ./pkgs/dist/deb/sftpgo_${{ steps.build_linux_pkgs.outputs.pkg-version }}-1_${{ matrix.deb-arch}}.deb
retention-days: 1
- name: Upload RPM Package
uses: actions/upload-artifact@v7
with:
name: sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-1.${{ matrix.rpm-arch}}.rpm
path: ./pkgs/dist/rpm/sftpgo-${{ steps.build_linux_pkgs.outputs.pkg-version }}-1.${{ matrix.rpm-arch}}.rpm
retention-days: 1
prepare-linux-bundle:
name: Prepare Linux bundle
needs: prepare-linux
runs-on: ubuntu-latest
steps:
- name: Get versions
id: get_version
run: |
echo "SFTPGO_VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
shell: bash
- name: Download amd64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_x86_64.tar.xz
- name: Download arm64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_arm64.tar.xz
- name: Download ppc64le artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_ppc64le.tar.xz
- name: Download armv7 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_armv7.tar.xz
- name: Build bundle
shell: bash
run: |
mkdir -p bundle/{arm64,ppc64le,armv7}
cd bundle
tar xvf ../sftpgo_${SFTPGO_VERSION}_linux_x86_64.tar.xz
cd arm64
tar xvf ../../sftpgo_${SFTPGO_VERSION}_linux_arm64.tar.xz sftpgo
cd ../ppc64le
tar xvf ../../sftpgo_${SFTPGO_VERSION}_linux_ppc64le.tar.xz sftpgo
cd ../armv7
tar xvf ../../sftpgo_${SFTPGO_VERSION}_linux_armv7.tar.xz sftpgo
cd ..
tar cJvf sftpgo_${SFTPGO_VERSION}_linux_bundle.tar.xz *
cd ..
env:
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}
- name: Upload Linux bundle
uses: actions/upload-artifact@v7
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
path: ./bundle/sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
retention-days: 1
create-release:
name: Release
needs: [prepare-linux-bundle, prepare-sources-with-deps, prepare-mac, prepare-windows]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Get versions
id: get_version
run: |
SFTPGO_VERSION=${GITHUB_REF/refs\/tags\//}
PKG_VERSION=${SFTPGO_VERSION:1}
echo "SFTPGO_VERSION=${SFTPGO_VERSION}" >> $GITHUB_OUTPUT
echo "PKG_VERSION=${PKG_VERSION}" >> $GITHUB_OUTPUT
shell: bash
- name: Download amd64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_x86_64.tar.xz
- name: Download arm64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_arm64.tar.xz
- name: Download ppc64le artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_ppc64le.tar.xz
- name: Download armv7 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_armv7.tar.xz
- name: Download Linux bundle artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_linux_bundle.tar.xz
- name: Download Deb amd64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_amd64.deb
- name: Download Deb arm64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_arm64.deb
- name: Download Deb ppc64le artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_ppc64el.deb
- name: Download Deb armv7 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.PKG_VERSION }}-1_armhf.deb
- name: Download RPM x86_64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.x86_64.rpm
- name: Download RPM aarch64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.aarch64.rpm
- name: Download RPM ppc64le artifact
uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.ppc64le.rpm
- name: Download RPM armv7 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo-${{ steps.get_version.outputs.PKG_VERSION }}-1.armv7hl.rpm
- name: Download macOS x86_64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_macOS_x86_64.tar.xz
- name: Download macOS arm64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_macOS_arm64.tar.xz
- name: Download Windows installer x86_64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_x86_64.exe
- name: Download Windows installer arm64 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_arm64.exe
- name: Download Windows installer x86 artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_x86.exe
- name: Download Windows portable artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_windows_portable.zip
- name: Download source with deps artifact
uses: actions/download-artifact@v8
with:
name: sftpgo_${{ steps.get_version.outputs.SFTPGO_VERSION }}_src_with_deps.tar.xz
- name: Create release
run: |
mv sftpgo_windows_x86_64.exe sftpgo_${SFTPGO_VERSION}_windows_x86_64.exe
mv sftpgo_windows_arm64.exe sftpgo_${SFTPGO_VERSION}_windows_arm64.exe
mv sftpgo_windows_x86.exe sftpgo_${SFTPGO_VERSION}_windows_x86.exe
mv sftpgo_portable.zip sftpgo_${SFTPGO_VERSION}_windows_portable.zip
gh release create "${SFTPGO_VERSION}" -t "${SFTPGO_VERSION}"
gh release upload "${SFTPGO_VERSION}" sftpgo_*.xz --clobber
gh release upload "${SFTPGO_VERSION}" sftpgo-*.rpm --clobber
gh release upload "${SFTPGO_VERSION}" sftpgo_*.deb --clobber
gh release upload "${SFTPGO_VERSION}" sftpgo_*.exe --clobber
gh release upload "${SFTPGO_VERSION}" sftpgo_*.zip --clobber
gh release view "${SFTPGO_VERSION}"
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
SFTPGO_VERSION: ${{ steps.get_version.outputs.SFTPGO_VERSION }}

View file

@ -1,66 +0,0 @@
version: "2"
run:
issues-exit-code: 1
tests: true
linters:
enable:
- bodyclose
- dogsled
- dupl
- goconst
- gocyclo
- misspell
- revive
- rowserrcheck
- unconvert
- unparam
- whitespace
settings:
dupl:
threshold: 150
errcheck:
check-type-assertions: false
check-blank: false
goconst:
min-len: 3
min-occurrences: 3
gocyclo:
min-complexity: 15
# https://golangci-lint.run/usage/linters/#revive
revive:
rules:
- name: var-naming
severity: warning
disabled: true
exclude: [""]
arguments:
- ["ID"] # AllowList
- ["VM"] # DenyList
- - upper-case-const: true
- - skip-package-name-checks: true
exclusions:
generated: lax
presets:
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
settings:
gofmt:
simplify: true
goimports:
local-prefixes:
- github.com/drakkan/sftpgo
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

24
.travis.yml Normal file
View file

@ -0,0 +1,24 @@
language: go
os:
- linux
- osx
go:
- 1.13.x
- 1.14.x
env:
- GO111MODULE=on
before_script:
- sftpgo initprovider
install:
- go get -v -t ./...
script:
- go test -v ./... -coverprofile=coverage.txt -covermode=atomic
after_success:
- bash <(curl -s https://codecov.io/bash)

View file

@ -1 +0,0 @@
* @drakkan

View file

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@sftpgo.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,67 +0,0 @@
FROM golang:1.26-trixie AS builder
ENV GOFLAGS="-mod=readonly"
RUN apt-get update && apt-get -y upgrade && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /workspace
WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
RUN go mod download && go mod verify
ARG COMMIT_SHA
# This ARG allows to disable some optional features and it might be useful if you build the image yourself.
# For example you can disable S3 and GCS support like this:
# --build-arg FEATURES=nos3,nogcs
ARG FEATURES
COPY . .
RUN set -xe && \
export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --abbrev=8 --dirty)} && \
go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -v -o sftpgo
# Set to "true" to download the "official" plugins in /usr/local/bin
ARG DOWNLOAD_PLUGINS=false
RUN if [ "${DOWNLOAD_PLUGINS}" = "true" ]; then apt-get update && apt-get install --no-install-recommends -y curl && ./docker/scripts/download-plugins.sh; fi
FROM debian:trixie-slim
# Set to "true" to install jq
ARG INSTALL_OPTIONAL_PACKAGES=false
RUN apt-get update && apt-get -y upgrade && apt-get install --no-install-recommends -y ca-certificates media-types && rm -rf /var/lib/apt/lists/*
RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apt-get update && apt-get install --no-install-recommends -y jq && rm -rf /var/lib/apt/lists/*; fi
RUN mkdir -p /etc/sftpgo /var/lib/sftpgo /usr/share/sftpgo /srv/sftpgo/data /srv/sftpgo/backups
RUN groupadd --system -g 1000 sftpgo && \
useradd --system --gid sftpgo --no-create-home \
--home-dir /var/lib/sftpgo --shell /usr/sbin/nologin \
--comment "SFTPGo user" --uid 1000 sftpgo
COPY --from=builder /workspace/sftpgo.json /etc/sftpgo/sftpgo.json
COPY --from=builder /workspace/templates /usr/share/sftpgo/templates
COPY --from=builder /workspace/static /usr/share/sftpgo/static
COPY --from=builder /workspace/openapi /usr/share/sftpgo/openapi
COPY --from=builder /workspace/sftpgo /usr/local/bin/sftpgo-plugin-* /usr/local/bin/
# Log to the stdout so the logs will be available using docker logs
ENV SFTPGO_LOG_FILE_PATH=""
# Modify the default configuration file
RUN sed -i 's|"users_base_dir": "",|"users_base_dir": "/srv/sftpgo/data",|' /etc/sftpgo/sftpgo.json && \
sed -i 's|"backups"|"/srv/sftpgo/backups"|' /etc/sftpgo/sftpgo.json
RUN chown -R sftpgo:sftpgo /etc/sftpgo /srv/sftpgo && chown sftpgo:sftpgo /var/lib/sftpgo && chmod 700 /srv/sftpgo/backups
WORKDIR /var/lib/sftpgo
USER 1000:1000
CMD ["sftpgo", "serve"]

View file

@ -1,60 +0,0 @@
FROM golang:1.26-alpine3.23 AS builder
ENV GOFLAGS="-mod=readonly"
RUN apk -U upgrade --no-cache && apk add --update --no-cache bash ca-certificates curl git gcc g++
RUN mkdir -p /workspace
WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
RUN go mod download && go mod verify
ARG COMMIT_SHA
# This ARG allows to disable some optional features and it might be useful if you build the image yourself.
# For example you can disable S3 and GCS support like this:
# --build-arg FEATURES=nos3,nogcs
ARG FEATURES
COPY . .
RUN set -xe && \
export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --abbrev=8 --dirty)} && \
go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -v -o sftpgo
FROM alpine:3.23
# Set to "true" to install jq
ARG INSTALL_OPTIONAL_PACKAGES=false
RUN apk -U upgrade --no-cache && apk add --update --no-cache ca-certificates tzdata mailcap
RUN if [ "${INSTALL_OPTIONAL_PACKAGES}" = "true" ]; then apk add --update --no-cache jq; fi
RUN mkdir -p /etc/sftpgo /var/lib/sftpgo /usr/share/sftpgo /srv/sftpgo/data /srv/sftpgo/backups
RUN addgroup -g 1000 -S sftpgo && \
adduser -u 1000 -h /var/lib/sftpgo -s /sbin/nologin -G sftpgo -S -D -H -g "SFTPGo user" sftpgo
COPY --from=builder /workspace/sftpgo.json /etc/sftpgo/sftpgo.json
COPY --from=builder /workspace/templates /usr/share/sftpgo/templates
COPY --from=builder /workspace/static /usr/share/sftpgo/static
COPY --from=builder /workspace/openapi /usr/share/sftpgo/openapi
COPY --from=builder /workspace/sftpgo /usr/local/bin/
# Log to the stdout so the logs will be available using docker logs
ENV SFTPGO_LOG_FILE_PATH=""
# Modify the default configuration file
RUN sed -i 's|"users_base_dir": "",|"users_base_dir": "/srv/sftpgo/data",|' /etc/sftpgo/sftpgo.json && \
sed -i 's|"backups"|"/srv/sftpgo/backups"|' /etc/sftpgo/sftpgo.json
RUN chown -R sftpgo:sftpgo /etc/sftpgo /srv/sftpgo && chown sftpgo:sftpgo /var/lib/sftpgo && chmod 700 /srv/sftpgo/backups
WORKDIR /var/lib/sftpgo
USER 1000:1000
CMD ["sftpgo", "serve"]

View file

@ -1,57 +0,0 @@
FROM golang:1.26-trixie AS builder
ENV CGO_ENABLED=0 GOFLAGS="-mod=readonly"
RUN apt-get update && apt-get -y upgrade && apt-get install --no-install-recommends -y media-types && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /workspace
WORKDIR /workspace
ARG GOPROXY
COPY go.mod go.sum ./
RUN go mod download && go mod verify
ARG COMMIT_SHA
# This ARG allows to disable some optional features and it might be useful if you build the image yourself.
# For this variant we disable SQLite support since it requires CGO and so a C runtime which is not installed
# in distroless/static-* images
ARG FEATURES
COPY . .
RUN set -xe && \
export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --abbrev=8 --dirty)} && \
go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/internal/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/v2/internal/version.date=`date -u +%FT%TZ`" -v -o sftpgo
# Modify the default configuration file
RUN sed -i 's|"users_base_dir": "",|"users_base_dir": "/srv/sftpgo/data",|' sftpgo.json && \
sed -i 's|"backups"|"/srv/sftpgo/backups"|' sftpgo.json && \
sed -i 's|"sqlite"|"bolt"|' sftpgo.json
RUN mkdir /etc/sftpgo /var/lib/sftpgo /srv/sftpgo
FROM gcr.io/distroless/static-debian13
COPY --from=builder --chown=1000:1000 /etc/sftpgo /etc/sftpgo
COPY --from=builder --chown=1000:1000 /srv/sftpgo /srv/sftpgo
COPY --from=builder --chown=1000:1000 /var/lib/sftpgo /var/lib/sftpgo
COPY --from=builder --chown=1000:1000 /workspace/sftpgo.json /etc/sftpgo/sftpgo.json
COPY --from=builder /workspace/templates /usr/share/sftpgo/templates
COPY --from=builder /workspace/static /usr/share/sftpgo/static
COPY --from=builder /workspace/openapi /usr/share/sftpgo/openapi
COPY --from=builder /workspace/sftpgo /usr/local/bin/
COPY --from=builder /etc/mime.types /etc/mime.types
# Log to the stdout so the logs will be available using docker logs
ENV SFTPGO_LOG_FILE_PATH=""
# These env vars are required to avoid the following error when calling user.Current():
# unable to get the current user: user: Current requires cgo or $USER set in environment
ENV USER=sftpgo
ENV HOME=/var/lib/sftpgo
WORKDIR /var/lib/sftpgo
USER 1000:1000
CMD ["sftpgo", "serve"]

145
LICENSE
View file

@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@ -7,15 +7,17 @@
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
@ -60,7 +72,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

12
NOTICE
View file

@ -1,12 +0,0 @@
Additional terms under GNU AGPL version 3 section 7.3(b) and 13.1:
If you have included SFTPGo so that it is offered through any network
interactions, including by means of an external user interface, or
any other integration, even without modifying its source code and then
SFTPGo is partially, fully or optionally configured via your frontend,
you must provide reasonable but clear attribution to the SFTPGo project
and its author(s), not imply any endorsement by or affiliation with the
SFTPGo project, and you must prominently offer all users interacting
with it remotely through a computer network an opportunity to receive
the Corresponding Source of the SFTPGo version you include by providing
a link to the Corresponding Source in the SFTPGo source code repository.

237
README.md
View file

@ -1,131 +1,192 @@
# SFTPGo
[![CI Status](https://github.com/drakkan/sftpgo/workflows/CI/badge.svg)](https://github.com/drakkan/sftpgo/workflows/CI/badge.svg)
[![License: AGPL-3.0-only](https://img.shields.io/badge/License-AGPLv3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
[![Build Status](https://travis-ci.org/drakkan/sftpgo.svg?branch=master)](https://travis-ci.org/drakkan/sftpgo) [![Code Coverage](https://codecov.io/gh/drakkan/sftpgo/branch/master/graph/badge.svg)](https://codecov.io/gh/drakkan/sftpgo/branch/master) [![Go Report Card](https://goreportcard.com/badge/github.com/drakkan/sftpgo)](https://goreportcard.com/report/github.com/drakkan/sftpgo) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
Full-featured and highly configurable event-driven file transfer solution. Server protocols: SFTP, HTTP/S, FTP/S, WebDAV. Storage backends: local filesystem, encrypted local filesystem, S3 (compatible) Object Storage, Google Cloud Storage, Azure Blob Storage, other SFTP servers.
Fully featured and highly configurable SFTP server, written in Go
With SFTPGo you can leverage local and cloud storage backends for exchanging and storing files internally or with business partners using the same tools and processes you are already familiar with.
## Features
## Project Status & Editions
- Each account is chrooted to its home directory.
- SFTP accounts are virtual accounts stored in a "data provider".
- SQLite, MySQL, PostgreSQL, bbolt (key/value store in pure Go) and in-memory data providers are supported.
- Public key and password authentication. Multiple public keys per user are supported.
- Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication.
- Per user authentication methods. You can, for example, deny one or more authentication methods to one or more users.
- Custom authentication via external programs is supported.
- Dynamic user modification before login via external programs is supported.
- Quota support: accounts can have individual quota expressed as max total size and/or max number of files.
- Bandwidth throttling is supported, with distinct settings for upload and download.
- Per user maximum concurrent sessions.
- Per user and per directory permission management: list directory contents, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group and mode, change access and modification times.
- Per user files/folders ownership mapping: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (\*NIX only).
- Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
- Per user and per directory file extensions filters are supported: files can be allowed or denied based on their extensions.
- Virtual folders are supported: directories outside the user home directory can be exposed as virtual folders.
- Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
- Automatically terminating idle connections.
- Atomic uploads are configurable.
- Support for Git repositories over SSH.
- SCP and rsync are supported.
- Support for serving local filesystem, S3 Compatible Object Storage and Google Cloud Storage over SFTP/SCP.
- [Prometheus metrics](./docs/metrics.md) are exposed.
- Support for HAProxy PROXY protocol: you can proxy and/or load balance the SFTP/SCP service without losing the information about the client's address.
- [REST API](./docs/rest-api.md) for users management, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection.
- [Web based administration interface](./docs/web-admin.md) to easily manage users and connections.
- Easy [migration](./scripts#convert-users-from-other-stores) from Linux system user accounts.
- [Portable mode](./docs/portable-mode.md): a convenient way to share a single directory on demand.
- Configuration format is at your choice: JSON, TOML, YAML, HCL, envfile are supported.
- Log files are accurate and they are saved in the easily parsable JSON format ([more information](./docs/logs.md)).
SFTPGo is an open-source project with a sustainable business model. We offer two editions to suit different requirements, ensuring the project remains healthy and maintained for everyone.
## Platforms
### Open Source (Community)
SFTPGo is developed and tested on Linux. After each commit, the code is automatically built and tested on Linux and macOS using Travis CI.
The test cases are regularly manually executed and passed on Windows. Other UNIX variants such as \*BSD should work too.
Free, Copyleft (AGPLv3), Community Supported. The Community edition is a fully functional, production-ready solution widely adopted worldwide. It includes all the core protocols, storage backends, and the WebAdmin/WebClient UIs. It is ideal for:
## Requirements
- Standard file transfer needs.
- Integrating storage backends (S3, GCS, Azure Blob) with legacy protocols.
- Projects that are comfortable with AGPLv3 licensing.
- Go 1.13 or higher as build only dependency.
- A suitable SQL server or key/value store to use as data provider: PostgreSQL 9.4+ or MySQL 5.6+ or SQLite 3.x or bbolt 1.3.x
### SFTPGo Enterprise
## Installation
Commercial License, Professional Support, ISO 27001 Vendor. The Enterprise edition is built on the same core but extends it for mission-critical environments, compliance-heavy industries, and advanced workflows. It is a drop-in replacement (seamless upgrade).
Binary releases for Linux, macOS, and Windows are available. Please visit the [releases](https://github.com/drakkan/sftpgo/releases "releases") page.
| Feature | Open Source (Community) | Enterprise Edition |
| :--- | :--- | :--- |
| **License Type** | AGPLv3 (Copyleft) | **Commercial License**<br/>Proprietary/No Copyleft |
| **Vendor Compliance** | Not Applicable<br/>Community Project | **Certified Vendor**<br/>ISO 27001 & Supply Chain Validation |
| **Support** | Community (GitHub) | **Direct from Authors** |
| **Cloud Storage Engine** | Standard | **High Performance & Scalable**<br/>In-memory streaming (no local temp files) and up to 70% faster |
| **High Availability (HA)** | Standard<br/>Shared DB & Storage | **Advanced**<br/>Enhanced event handling and optimized instance coordination |
| **Automation Logic** | Simple Placeholders | **Dynamic Logic & Virtual Folders**<br/>Conditions, loops, route data across storage backends |
| **Data Lifecycle** | Delete / Retain | **Smart Archiving**<br/>Move data to external Cloud/SFTP storage via Virtual Folders |
| **Email Data Ingestion** | - | **Native IMAP Integration**<br/>Auto-extract attachments from email to storage |
| **Public Sharing** | Standard Links | **Advanced & Collaborative**<br/>Email Authentication & Group Delegation |
| **Data Protection** | - | **Encryption & Scanning**<br/>Automated PGP, Antivirus & DLP via ICAP |
| **Advanced Identity (SSO)** | Standard | **Extended Controls**<br/>Advanced Single Sign-On parameters |
| **Document Editing** | - | **Included**<br/>View, edit, and co-author in browser |
Sample Dockerfiles for [Debian](https://www.debian.org "Debian") and [Alpine](https://alpinelinux.org "Alpine") are available inside the source tree [docker](./docker "docker") directory.
**Note**: We are committed to keeping the Open Source edition powerful and maintained. The Enterprise edition helps fund the development of the entire SFTPGo ecosystem.
Some Linux distro packages are available:
## Sponsors
- For Arch Linux via AUR:
- [sftpgo](https://aur.archlinux.org/packages/sftpgo/). This package follows stable releases. It requires `git`, `gcc` and `go` to build.
- [sftpgo-bin](https://aur.archlinux.org/packages/sftpgo-bin/). This package follows stable releases downloading the prebuilt linux binary from GitHub. It does not require `git`, `gcc` and `go` to build.
- [sftpgo-git](https://aur.archlinux.org/packages/sftpgo-git/). This package builds and installs the latest git master. It requires `git`, `gcc` and `go` to build.
If you rely on SFTPGo in your projects, consider becoming a [sponsor](https://github.com/sponsors/drakkan).
Alternately, you can [build from source](./docs/build-from-source.md).
Your sponsorship helps cover maintenance, security updates and ongoing development of the open-source edition.
## Configuration
### Thank you to our sponsors
A full explanation of all configuration methods can be found [here](./docs/full-configuration.md).
#### Platinum sponsors
Please make sure to [initialize the data provider](#data-provider-initialization) before running the daemon!
[<img src="./img/Aledade_logo.png" alt="Aledade logo" width="202" height="70">](https://www.aledade.com/)
</br></br>
[<img src="./img/jumptrading.png" alt="Jump Trading logo" width="362" height="63">](https://www.jumptrading.com/)
</br></br>
[<img src="./img/wpengine.png" alt="WP Engine logo" width="331" height="63">](https://wpengine.com/)
To start the SFTP server with default settings, simply run:
#### Silver sponsors
```bash
sftpgo serve
```
[<img src="./img/IDCS.png" alt="IDCS logo" width="212" height="51">](https://idcs.ip-paris.fr/)
Check out [this documentation](./docs/service.md) if you want to run SFTPGo as a service.
#### Bronze sponsors
### Data provider initialization
[<img src="./img/7digital.png" alt="7digital logo" width="178" height="56">](https://www.7digital.com/)
</br></br>
[<img src="./img/servinga.png" alt="servinga logo" width="258" height="56">](https://servinga.com/)
</br></br>
[<img src="./img/reui.png" alt="ReUI logo" width="151" height="56">](https://www.reui.io/)
Before starting the SFTPGo server, please ensure that the configured data provider is properly initialized.
## Documentation
SQL based data providers (SQLite, MySQL, PostgreSQL) require the creation of a database containing the required tables. Memory and bolt data providers do not require an initialization.
You can explore all supported features and configuration options at [docs.sftpgo.com](https://docs.sftpgo.com/latest/).
After configuring the data provider using the configuration file, you can create the required database structure using the `initprovider` command.
For SQLite provider, the `initprovider` command will auto create the database file, if missing, and the required tables.
For PostgreSQL and MySQL providers, you need to create the configured database, and the `initprovider` command will create the required tables.
**Note:** The link above refers to the **Community Edition**.
For details on **Enterprise Edition**, please refer to the [Enterprise Documentation](https://docs.sftpgo.com/enterprise/).
For example, you can simply execute the following command from the configuration directory:
## Support
```bash
sftpgo initprovider
```
- **Community Support**: use [GitHub Discussions](https://github.com/drakkan/sftpgo/discussions) to ask questions, share feedback, and engage with other users.
- **Commercial Support**: If you require guaranteed SLAs, expert guidance, or the advanced features listed above, check out [SFTPGo Enterprise](https://sftpgo.com).
Take a look at the CLI usage to learn how to specify a different configuration file:
SFTPGo Enterprise is available as:
```bash
sftpgo initprovider --help
```
- On-premises: Full control on your infrastructure. More details: [sftpgo.com/on-premises](https://sftpgo.com/on-premises)
- Fully managed SaaS: We handle the infrastructure. More details: [sftpgo.com/saas](https://sftpgo.com/saas)
The `initprovider` command is enough for new installations. From now on, the database structure will be automatically checked and updated, if required, at startup.
## Internationalization
#### Upgrading
The translations are available via [Crowdin](https://crowdin.com/project/sftpgo), who have granted us an open source license.
If you are upgrading from version 0.9.5 or before, you have to manually execute the SQL scripts to create the required database structure. These scripts can be found inside the source tree [sql](./sql "sql") directory. The SQL scripts filename is, by convention, the date as `YYYYMMDD` and the suffix `.sql`. You need to apply all the SQL scripts for your database ordered by name. For example, `20190828.sql` must be applied before `20191112.sql`, and so on.
Example for SQLite: `find sql/sqlite/ -type f -iname '*.sql' -print | sort -n | xargs cat | sqlite3 sftpgo.db`.
After applying these scripts, your database structure is the same as the one obtained using `initprovider` for new installations, so from now on, you don't have to manually upgrade your database anymore.
Before translating please take a look at our contribution [guidelines](https://docs.sftpgo.com/latest/web-interfaces/#internationalization).
## Authentication options
## Release Cadence
### External Authentication
SFTPGo follows a feature-driven release cycle.
Custom authentication methods can easily be added. SFTPGo supports external authentication modules, and writing a new backend can be as simple as a few lines of shell script. More information can be found [here](./docs/external-auth.md).
- Enterprise Edition: Receives major new features first and follows a faster [release cadence](https://docs.sftpgo.com/enterprise/changelog/).
- Community Edition: Remains maintained, receiving bug fixes, security updates, and updates to core features.
### Keyboard Interactive Authentication
Keyboard interactive authentication is, in general, a series of questions asked by the server with responses provided by the client.
This authentication method is typically used for multi-factor authentication.
More information can be found [here](./docs/keyboard-interactive.md).
## Dynamic user modification
The user configuration, retrieved from the data provider, can be modified by an external program. More information about this can be found [here](./docs/dynamic-user-mod.md).
## Custom Actions
SFTPGo allows you to configure custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
More information about custom actions can be found [here](./docs/custom-actions.md).
## Storage backends
### S3 Compabible Object Storage backends
Each user can be mapped to whole bucket or to a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP. More information about S3 integration can be found [here](./docs/s3.md).
### Google Cloud Storage backend
Each user can be mapped with a Google Cloud Storage bucket or a bucket virtual folder. This way, the mapped bucket/virtual folder is exposed over SFTP/SCP. More information about Google Cloud Storage integration can be found [here](./docs/google-cloud-storage.md).
### Other Storage backends
Adding new storage backends is quite easy:
- implement the [Fs interface](./vfs/vfs.go#L18 "interface for filesystem backends").
- update the user method `GetFilesystem` to return the new backend
- update the web interface and the REST API CLI
- add the flags for the new storage backed to the `portable` mode
Anyway, some backends require a pay per use account (or they offer free account for a limited time period only). To be able to add support for such backends or to review pull requests, please provide a test account. The test account must be available for enough time to be able to maintain the backend and do basic tests before each new release.
## Brute force protection
The [connection failed logs](./docs/logs.md) can be used for integration in tools such as [Fail2ban](http://www.fail2ban.org/). Example of [jails](./fail2ban/jails) and [filters](./fail2ban/filters) working with `systemd`/`journald` are available in fail2ban directory.
## Account's configuration properties
Details information about account configuration properties can be found [here](./docs/account.md).
## Performance
SFTPGo can easily saturate a Gigabit connection on low end hardware with no special configuration, this is generally enough for most use cases.
More in-depth analysis of performance can be found [here](./docs/performance.md).
## Acknowledgements
SFTPGo makes use of the third party libraries listed inside [go.mod](./go.mod).
- [pkg/sftp](https://github.com/pkg/sftp)
- [go-chi](https://github.com/go-chi/chi)
- [zerolog](https://github.com/rs/zerolog)
- [lumberjack](https://gopkg.in/natefinch/lumberjack.v2)
- [argon2id](https://github.com/alexedwards/argon2id)
- [go-sqlite3](https://github.com/mattn/go-sqlite3)
- [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql)
- [bbolt](https://github.com/etcd-io/bbolt)
- [lib/pq](https://github.com/lib/pq)
- [viper](https://github.com/spf13/viper)
- [cobra](https://github.com/spf13/cobra)
- [xid](https://github.com/rs/xid)
- [nathanaelle/password](https://github.com/nathanaelle/password)
- [PipeAt](https://github.com/eikenb/pipeat)
- [ZeroConf](https://github.com/grandcat/zeroconf)
- [SB Admin 2](https://github.com/BlackrockDigital/startbootstrap-sb-admin-2)
- [shlex](https://github.com/google/shlex)
- [go-proxyproto](https://github.com/pires/go-proxyproto)
We are very grateful to all the people who contributed with ideas and/or pull requests.
Thank you to [ysura](https://www.ysura.com/) for granting us stable access to a test AWS S3 account.
Thank you to [KeenThemes](https://keenthemes.com/) for granting us a custom license to use their amazing [themes](https://keenthemes.com/bootstrap-templates) for the SFTPGo WebAdmin and WebClient user interfaces, across both the Open Source and Open Core versions.
Thank you to [Crowdin](https://crowdin.com/) for granting us an Open Source License.
Thank you to [Incode](https://www.incode.it/) for helping us to improve the UI/UX.
Some code was initially taken from [Pterodactyl sftp server](https://github.com/pterodactyl/sftp-server)
## License
SFTPGo source code is licensed under the GNU AGPL-3.0-only with [additional terms](./NOTICE).
The [theme](https://keenthemes.com/bootstrap-templates) used in WebAdmin and WebClient user interfaces is proprietary, this means:
- KeenThemes HTML/CSS/JS components are allowed for use only within the SFTPGo product and restricted to be used in a resealable HTML template that can compete with KeenThemes products anyhow.
- The SFTPGo WebAdmin and WebClient user interfaces (HTML, CSS and JS components) based on this theme are allowed for use only within the SFTPGo product and therefore cannot be used in derivative works/products without an explicit grant from the [SFTPGo Team](mailto:support@sftpgo.com).
More information about [compliance](https://sftpgo.com/compliance.html).
**Note:** We do not provide legal advice. If you have questions about license compliance or whether your use case is permitted under the license terms, please consult your legal team.
## Copyright
Copyright (C) 2019 - 2026 Nicola Murino
GNU GPLv3

View file

@ -1,20 +0,0 @@
# Security Policy
## Supported Versions
We actively maintain the latest stable release of SFTPGo. While we strive to keep the Open Source version secure and up-to-date, maintenance is performed on a best-effort basis by the community and contributors.
## Scope and Dependency Policy
Our security advisories focus on vulnerabilities found within the **SFTPGo codebase itself**.
To ensure the long-term sustainability of the project, we handle upstream dependencies (like the Go standard library, external packages, or Docker base images) as follows:
- Community Updates: For the Open Source version, vulnerabilities in upstream components (such as the Go standard library or third-party packages) are addressed during our **regular release cycles**. We generally do not provide immediate, out-of-band or ad-hoc releases to address dependency-only CVEs.
- Empowering Users: One of the strengths of SFTPGo being open-source is that you have full control. If your security scanners require an immediate fix, you can always rebuild the project using the latest patched Go toolchain or updated dependencies.
- Compatibility: We are committed to keeping SFTPGo compatible with the latest stable Go compiler. If an upstream fix breaks SFTPGo, fixing that becomes a priority for us.
- Professional Needs: We understand that some organizations have strict compliance requirements or internal SLAs that require guaranteed, immediate response times and out-of-band patches. For these cases, we offer [SFTPGo Enterprise](https://sftpgo.com/on-premises) to cover the additional maintenance and support overhead.
## Reporting a Vulnerability
To report (possible) security issues in SFTPGo, please either send a mail to the [SFTPGo Team](mailto:support@sftpgo.com) or use Github's [private reporting feature](https://github.com/drakkan/sftpgo/security/advisories/new).

50
cmd/initprovider.go Normal file
View file

@ -0,0 +1,50 @@
package cmd
import (
"github.com/drakkan/sftpgo/config"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
initProviderCmd = &cobra.Command{
Use: "initprovider",
Short: "Initializes the configured data provider",
Long: `This command reads the data provider connection details from the specified configuration file and creates the initial structure.
Some data providers such as bolt and memory does not require an initialization.
For SQLite provider the database file will be auto created if missing.
For PostgreSQL and MySQL providers you need to create the configured database, this command will create the required tables.
To initialize the data provider from the configuration directory simply use:
sftpgo initprovider
Please take a look at the usage below to customize the options.`,
Run: func(cmd *cobra.Command, args []string) {
logger.DisableLogger()
logger.EnableConsoleLogger(zerolog.DebugLevel)
configDir = utils.CleanDirInput(configDir)
config.LoadConfig(configDir, configFile)
providerConf := config.GetProviderConf()
logger.DebugToConsole("Initializing provider: %#v config file: %#v", providerConf.Driver, viper.ConfigFileUsed())
err := dataprovider.InitializeDatabase(providerConf, configDir)
if err == nil {
logger.DebugToConsole("Data provider successfully initialized")
} else {
logger.WarnToConsole("Unable to initialize data provider: %v", err)
}
},
}
)
func init() {
rootCmd.AddCommand(initProviderCmd)
addConfigFlags(initProviderCmd)
}

53
cmd/install_windows.go Normal file
View file

@ -0,0 +1,53 @@
package cmd
import (
"fmt"
"github.com/drakkan/sftpgo/service"
"github.com/drakkan/sftpgo/utils"
"github.com/spf13/cobra"
)
var (
installCmd = &cobra.Command{
Use: "install",
Short: "Install SFTPGo as Windows Service",
Long: `To install the SFTPGo Windows Service with the default values for the command line flags simply use:
sftpgo service install
Please take a look at the usage below to customize the startup options`,
Run: func(cmd *cobra.Command, args []string) {
s := service.Service{
ConfigDir: utils.CleanDirInput(configDir),
ConfigFile: configFile,
LogFilePath: logFilePath,
LogMaxSize: logMaxSize,
LogMaxBackups: logMaxBackups,
LogMaxAge: logMaxAge,
LogCompress: logCompress,
LogVerbose: logVerbose,
Shutdown: make(chan bool),
}
winService := service.WindowsService{
Service: s,
}
serviceArgs := []string{"service", "start"}
customFlags := getCustomServeFlags()
if len(customFlags) > 0 {
serviceArgs = append(serviceArgs, customFlags...)
}
err := winService.Install(serviceArgs...)
if err != nil {
fmt.Printf("Error installing service: %v\r\n", err)
} else {
fmt.Printf("Service installed!\r\n")
}
},
}
)
func init() {
serviceCmd.AddCommand(installCmd)
addServeFlags(installCmd)
}

226
cmd/portable.go Normal file
View file

@ -0,0 +1,226 @@
package cmd
import (
"encoding/base64"
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/service"
"github.com/drakkan/sftpgo/sftpd"
"github.com/drakkan/sftpgo/vfs"
"github.com/spf13/cobra"
)
var (
directoryToServe string
portableSFTPDPort int
portableAdvertiseService bool
portableAdvertiseCredentials bool
portableUsername string
portablePassword string
portableLogFile string
portablePublicKeys []string
portablePermissions []string
portableSSHCommands []string
portableAllowedExtensions []string
portableDeniedExtensions []string
portableFsProvider int
portableS3Bucket string
portableS3Region string
portableS3AccessKey string
portableS3AccessSecret string
portableS3Endpoint string
portableS3StorageClass string
portableS3KeyPrefix string
portableGCSBucket string
portableGCSCredentialsFile string
portableGCSAutoCredentials int
portableGCSStorageClass string
portableGCSKeyPrefix string
portableCmd = &cobra.Command{
Use: "portable",
Short: "Serve a single directory",
Long: `To serve the current working directory with auto generated credentials simply use:
sftpgo portable
Please take a look at the usage below to customize the serving parameters`,
Run: func(cmd *cobra.Command, args []string) {
portableDir := directoryToServe
if !filepath.IsAbs(portableDir) {
if portableFsProvider == 0 {
portableDir, _ = filepath.Abs(portableDir)
} else {
portableDir = os.TempDir()
}
}
permissions := make(map[string][]string)
permissions["/"] = portablePermissions
portableGCSCredentials := ""
if portableFsProvider == 2 && len(portableGCSCredentialsFile) > 0 {
fi, err := os.Stat(portableGCSCredentialsFile)
if err != nil {
fmt.Printf("Invalid GCS credentials file: %v\n", err)
return
}
if fi.Size() > 1048576 {
fmt.Printf("Invalid GCS credentials file: %#v is too big %v/1048576 bytes\n", portableGCSCredentialsFile,
fi.Size())
return
}
creds, err := ioutil.ReadFile(portableGCSCredentialsFile)
if err != nil {
fmt.Printf("Unable to read credentials file: %v\n", err)
}
portableGCSCredentials = base64.StdEncoding.EncodeToString(creds)
portableGCSAutoCredentials = 0
}
service := service.Service{
ConfigDir: filepath.Clean(defaultConfigDir),
ConfigFile: defaultConfigName,
LogFilePath: portableLogFile,
LogMaxSize: defaultLogMaxSize,
LogMaxBackups: defaultLogMaxBackup,
LogMaxAge: defaultLogMaxAge,
LogCompress: defaultLogCompress,
LogVerbose: defaultLogVerbose,
Shutdown: make(chan bool),
PortableMode: 1,
PortableUser: dataprovider.User{
Username: portableUsername,
Password: portablePassword,
PublicKeys: portablePublicKeys,
Permissions: permissions,
HomeDir: portableDir,
Status: 1,
FsConfig: dataprovider.Filesystem{
Provider: portableFsProvider,
S3Config: vfs.S3FsConfig{
Bucket: portableS3Bucket,
Region: portableS3Region,
AccessKey: portableS3AccessKey,
AccessSecret: portableS3AccessSecret,
Endpoint: portableS3Endpoint,
StorageClass: portableS3StorageClass,
KeyPrefix: portableS3KeyPrefix,
},
GCSConfig: vfs.GCSFsConfig{
Bucket: portableGCSBucket,
Credentials: portableGCSCredentials,
AutomaticCredentials: portableGCSAutoCredentials,
StorageClass: portableGCSStorageClass,
KeyPrefix: portableGCSKeyPrefix,
},
},
Filters: dataprovider.UserFilters{
FileExtensions: parseFileExtensionsFilters(),
},
},
}
if err := service.StartPortableMode(portableSFTPDPort, portableSSHCommands, portableAdvertiseService,
portableAdvertiseCredentials); err == nil {
service.Wait()
}
},
}
)
func init() {
portableCmd.Flags().StringVarP(&directoryToServe, "directory", "d", ".",
"Path to the directory to serve. This can be an absolute path or a path relative to the current directory")
portableCmd.Flags().IntVarP(&portableSFTPDPort, "sftpd-port", "s", 0, "0 means a random non privileged port")
portableCmd.Flags().StringSliceVarP(&portableSSHCommands, "ssh-commands", "c", sftpd.GetDefaultSSHCommands(),
"SSH commands to enable. \"*\" means any supported SSH command including scp")
portableCmd.Flags().StringVarP(&portableUsername, "username", "u", "", "Leave empty to use an auto generated value")
portableCmd.Flags().StringVarP(&portablePassword, "password", "p", "", "Leave empty to use an auto generated value")
portableCmd.Flags().StringVarP(&portableLogFile, logFilePathFlag, "l", "", "Leave empty to disable logging")
portableCmd.Flags().StringSliceVarP(&portablePublicKeys, "public-key", "k", []string{}, "")
portableCmd.Flags().StringSliceVarP(&portablePermissions, "permissions", "g", []string{"list", "download"},
"User's permissions. \"*\" means any permission")
portableCmd.Flags().StringArrayVar(&portableAllowedExtensions, "allowed-extensions", []string{},
"Allowed file extensions case insensitive. The format is /dir::ext1,ext2. For example: \"/somedir::.jpg,.png\"")
portableCmd.Flags().StringArrayVar(&portableDeniedExtensions, "denied-extensions", []string{},
"Denied file extensions case insensitive. The format is /dir::ext1,ext2. For example: \"/somedir::.jpg,.png\"")
portableCmd.Flags().BoolVarP(&portableAdvertiseService, "advertise-service", "S", true,
"Advertise SFTP service using multicast DNS")
portableCmd.Flags().BoolVarP(&portableAdvertiseCredentials, "advertise-credentials", "C", false,
"If the SFTP service is advertised via multicast DNS, this flag allows to put username/password inside the advertised TXT record")
portableCmd.Flags().IntVarP(&portableFsProvider, "fs-provider", "f", 0, "0 means local filesystem, 1 Amazon S3 compatible, "+
"2 Google Cloud Storage")
portableCmd.Flags().StringVar(&portableS3Bucket, "s3-bucket", "", "")
portableCmd.Flags().StringVar(&portableS3Region, "s3-region", "", "")
portableCmd.Flags().StringVar(&portableS3AccessKey, "s3-access-key", "", "")
portableCmd.Flags().StringVar(&portableS3AccessSecret, "s3-access-secret", "", "")
portableCmd.Flags().StringVar(&portableS3Endpoint, "s3-endpoint", "", "")
portableCmd.Flags().StringVar(&portableS3StorageClass, "s3-storage-class", "", "")
portableCmd.Flags().StringVar(&portableS3KeyPrefix, "s3-key-prefix", "", "Allows to restrict access to the virtual folder "+
"identified by this prefix and its contents")
portableCmd.Flags().StringVar(&portableGCSBucket, "gcs-bucket", "", "")
portableCmd.Flags().StringVar(&portableGCSStorageClass, "gcs-storage-class", "", "")
portableCmd.Flags().StringVar(&portableGCSKeyPrefix, "gcs-key-prefix", "", "Allows to restrict access to the virtual folder "+
"identified by this prefix and its contents")
portableCmd.Flags().StringVar(&portableGCSCredentialsFile, "gcs-credentials-file", "", "Google Cloud Storage JSON credentials file")
portableCmd.Flags().IntVar(&portableGCSAutoCredentials, "gcs-automatic-credentials", 1, "0 means explicit credentials using a JSON "+
"credentials file, 1 automatic")
rootCmd.AddCommand(portableCmd)
}
func parseFileExtensionsFilters() []dataprovider.ExtensionsFilter {
var extensions []dataprovider.ExtensionsFilter
for _, val := range portableAllowedExtensions {
p, exts := getExtensionsFilterValues(strings.TrimSpace(val))
if len(p) > 0 {
extensions = append(extensions, dataprovider.ExtensionsFilter{
Path: path.Clean(p),
AllowedExtensions: exts,
DeniedExtensions: []string{},
})
}
}
for _, val := range portableDeniedExtensions {
p, exts := getExtensionsFilterValues(strings.TrimSpace(val))
if len(p) > 0 {
found := false
for index, e := range extensions {
if path.Clean(e.Path) == path.Clean(p) {
extensions[index].DeniedExtensions = append(extensions[index].DeniedExtensions, exts...)
found = true
break
}
}
if !found {
extensions = append(extensions, dataprovider.ExtensionsFilter{
Path: path.Clean(p),
AllowedExtensions: []string{},
DeniedExtensions: exts,
})
}
}
}
return extensions
}
func getExtensionsFilterValues(value string) (string, []string) {
if strings.Contains(value, "::") {
dirExts := strings.Split(value, "::")
if len(dirExts) > 1 {
dir := strings.TrimSpace(dirExts[0])
exts := []string{}
for _, e := range strings.Split(dirExts[1], ",") {
cleanedExt := strings.TrimSpace(e)
if len(cleanedExt) > 0 {
exts = append(exts, cleanedExt)
}
}
if len(dir) > 0 && len(exts) > 0 {
return dir, exts
}
}
}
return "", nil
}

32
cmd/reload_windows.go Normal file
View file

@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/drakkan/sftpgo/service"
"github.com/spf13/cobra"
)
var (
reloadCmd = &cobra.Command{
Use: "reload",
Short: "Reload the SFTPGo Windows Service sending a \"paramchange\" request",
Run: func(cmd *cobra.Command, args []string) {
s := service.WindowsService{
Service: service.Service{
Shutdown: make(chan bool),
},
}
err := s.Reload()
if err != nil {
fmt.Printf("Error reloading service: %v\r\n", err)
} else {
fmt.Printf("Service reloaded!\r\n")
}
},
}
)
func init() {
serviceCmd.AddCommand(reloadCmd)
}

174
cmd/root.go Normal file
View file

@ -0,0 +1,174 @@
// Package cmd provides Command Line Interface support
package cmd
import (
"fmt"
"os"
"strconv"
"github.com/drakkan/sftpgo/config"
"github.com/drakkan/sftpgo/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const (
logSender = "cmd"
configDirFlag = "config-dir"
configDirKey = "config_dir"
configFileFlag = "config-file"
configFileKey = "config_file"
logFilePathFlag = "log-file-path"
logFilePathKey = "log_file_path"
logMaxSizeFlag = "log-max-size"
logMaxSizeKey = "log_max_size"
logMaxBackupFlag = "log-max-backups"
logMaxBackupKey = "log_max_backups"
logMaxAgeFlag = "log-max-age"
logMaxAgeKey = "log_max_age"
logCompressFlag = "log-compress"
logCompressKey = "log_compress"
logVerboseFlag = "log-verbose"
logVerboseKey = "log_verbose"
defaultConfigDir = "."
defaultConfigName = config.DefaultConfigName
defaultLogFile = "sftpgo.log"
defaultLogMaxSize = 10
defaultLogMaxBackup = 5
defaultLogMaxAge = 28
defaultLogCompress = false
defaultLogVerbose = true
)
var (
configDir string
configFile string
logFilePath string
logMaxSize int
logMaxBackups int
logMaxAge int
logCompress bool
logVerbose bool
rootCmd = &cobra.Command{
Use: "sftpgo",
Short: "Full featured and highly configurable SFTP server",
}
)
func init() {
version := utils.GetAppVersion()
rootCmd.Flags().BoolP("version", "v", false, "")
rootCmd.Version = version.GetVersionAsString()
rootCmd.SetVersionTemplate(`{{printf "SFTPGo version: "}}{{printf "%s" .Version}}
`)
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func addConfigFlags(cmd *cobra.Command) {
viper.SetDefault(configDirKey, defaultConfigDir)
viper.BindEnv(configDirKey, "SFTPGO_CONFIG_DIR")
cmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey),
"Location for SFTPGo config dir. This directory should contain the \"sftpgo\" configuration file or the configured "+
"config-file and it is used as the base for files with a relative path (eg. the private keys for the SFTP server, "+
"the SQLite database if you use SQLite as data provider). This flag can be set using SFTPGO_CONFIG_DIR env var too.")
viper.BindPFlag(configDirKey, cmd.Flags().Lookup(configDirFlag))
viper.SetDefault(configFileKey, defaultConfigName)
viper.BindEnv(configFileKey, "SFTPGO_CONFIG_FILE")
cmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey),
"Name for SFTPGo configuration file. It must be the name of a file stored in config-dir not the absolute path to the "+
"configuration file. The specified file name must have no extension we automatically load JSON, YAML, TOML, HCL and "+
"Java properties. Therefore if you set \"sftpgo\" then \"sftpgo.json\", \"sftpgo.yaml\" and so on are searched. "+
"This flag can be set using SFTPGO_CONFIG_FILE env var too.")
viper.BindPFlag(configFileKey, cmd.Flags().Lookup(configFileFlag))
}
func addServeFlags(cmd *cobra.Command) {
addConfigFlags(cmd)
viper.SetDefault(logFilePathKey, defaultLogFile)
viper.BindEnv(logFilePathKey, "SFTPGO_LOG_FILE_PATH")
cmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey),
"Location for the log file. Leave empty to write logs to the standard output. This flag can be set using SFTPGO_LOG_FILE_PATH "+
"env var too.")
viper.BindPFlag(logFilePathKey, cmd.Flags().Lookup(logFilePathFlag))
viper.SetDefault(logMaxSizeKey, defaultLogMaxSize)
viper.BindEnv(logMaxSizeKey, "SFTPGO_LOG_MAX_SIZE")
cmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey),
"Maximum size in megabytes of the log file before it gets rotated. This flag can be set using SFTPGO_LOG_MAX_SIZE "+
"env var too. It is unused if log-file-path is empty.")
viper.BindPFlag(logMaxSizeKey, cmd.Flags().Lookup(logMaxSizeFlag))
viper.SetDefault(logMaxBackupKey, defaultLogMaxBackup)
viper.BindEnv(logMaxBackupKey, "SFTPGO_LOG_MAX_BACKUPS")
cmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey),
"Maximum number of old log files to retain. This flag can be set using SFTPGO_LOG_MAX_BACKUPS env var too. "+
"It is unused if log-file-path is empty.")
viper.BindPFlag(logMaxBackupKey, cmd.Flags().Lookup(logMaxBackupFlag))
viper.SetDefault(logMaxAgeKey, defaultLogMaxAge)
viper.BindEnv(logMaxAgeKey, "SFTPGO_LOG_MAX_AGE")
cmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey),
"Maximum number of days to retain old log files. This flag can be set using SFTPGO_LOG_MAX_AGE env var too. "+
"It is unused if log-file-path is empty.")
viper.BindPFlag(logMaxAgeKey, cmd.Flags().Lookup(logMaxAgeFlag))
viper.SetDefault(logCompressKey, defaultLogCompress)
viper.BindEnv(logCompressKey, "SFTPGO_LOG_COMPRESS")
cmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey), "Determine if the rotated "+
"log files should be compressed using gzip. This flag can be set using SFTPGO_LOG_COMPRESS env var too. "+
"It is unused if log-file-path is empty.")
viper.BindPFlag(logCompressKey, cmd.Flags().Lookup(logCompressFlag))
viper.SetDefault(logVerboseKey, defaultLogVerbose)
viper.BindEnv(logVerboseKey, "SFTPGO_LOG_VERBOSE")
cmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey), "Enable verbose logs. "+
"This flag can be set using SFTPGO_LOG_VERBOSE env var too.")
viper.BindPFlag(logVerboseKey, cmd.Flags().Lookup(logVerboseFlag))
}
func getCustomServeFlags() []string {
result := []string{}
if configDir != defaultConfigDir {
configDir = utils.CleanDirInput(configDir)
result = append(result, "--"+configDirFlag)
result = append(result, configDir)
}
if configFile != defaultConfigName {
result = append(result, "--"+configFileFlag)
result = append(result, configFile)
}
if logFilePath != defaultLogFile {
result = append(result, "--"+logFilePathFlag)
result = append(result, logFilePath)
}
if logMaxSize != defaultLogMaxSize {
result = append(result, "--"+logMaxSizeFlag)
result = append(result, strconv.Itoa(logMaxSize))
}
if logMaxBackups != defaultLogMaxBackup {
result = append(result, "--"+logMaxBackupFlag)
result = append(result, strconv.Itoa(logMaxBackups))
}
if logMaxAge != defaultLogMaxAge {
result = append(result, "--"+logMaxAgeFlag)
result = append(result, strconv.Itoa(logMaxAge))
}
if logVerbose != defaultLogVerbose {
result = append(result, "--"+logVerboseFlag+"=false")
}
if logCompress != defaultLogCompress {
result = append(result, "--"+logCompressFlag+"=true")
}
return result
}

40
cmd/serve.go Normal file
View file

@ -0,0 +1,40 @@
package cmd
import (
"github.com/drakkan/sftpgo/service"
"github.com/drakkan/sftpgo/utils"
"github.com/spf13/cobra"
)
var (
serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the SFTP Server",
Long: `To start the SFTPGo with the default values for the command line flags simply use:
sftpgo serve
Please take a look at the usage below to customize the startup options`,
Run: func(cmd *cobra.Command, args []string) {
service := service.Service{
ConfigDir: utils.CleanDirInput(configDir),
ConfigFile: configFile,
LogFilePath: logFilePath,
LogMaxSize: logMaxSize,
LogMaxBackups: logMaxBackups,
LogMaxAge: logMaxAge,
LogCompress: logCompress,
LogVerbose: logVerbose,
Shutdown: make(chan bool),
}
if err := service.Start(); err == nil {
service.Wait()
}
},
}
)
func init() {
rootCmd.AddCommand(serveCmd)
addServeFlags(serveCmd)
}

16
cmd/service_windows.go Normal file
View file

@ -0,0 +1,16 @@
package cmd
import (
"github.com/spf13/cobra"
)
var (
serviceCmd = &cobra.Command{
Use: "service",
Short: "Install, Uninstall, Start, Stop, Reload and retrieve status for SFTPGo Windows Service",
}
)
func init() {
rootCmd.AddCommand(serviceCmd)
}

48
cmd/start_windows.go Normal file
View file

@ -0,0 +1,48 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/drakkan/sftpgo/service"
"github.com/drakkan/sftpgo/utils"
"github.com/spf13/cobra"
)
var (
startCmd = &cobra.Command{
Use: "start",
Short: "Start SFTPGo Windows Service",
Run: func(cmd *cobra.Command, args []string) {
configDir = utils.CleanDirInput(configDir)
if !filepath.IsAbs(logFilePath) && utils.IsFileInputValid(logFilePath) {
logFilePath = filepath.Join(configDir, logFilePath)
}
s := service.Service{
ConfigDir: configDir,
ConfigFile: configFile,
LogFilePath: logFilePath,
LogMaxSize: logMaxSize,
LogMaxBackups: logMaxBackups,
LogMaxAge: logMaxAge,
LogCompress: logCompress,
LogVerbose: logVerbose,
Shutdown: make(chan bool),
}
winService := service.WindowsService{
Service: s,
}
err := winService.RunService()
if err != nil {
fmt.Printf("Error starting service: %v\r\n", err)
} else {
fmt.Printf("Service started!\r\n")
}
},
}
)
func init() {
serviceCmd.AddCommand(startCmd)
addServeFlags(startCmd)
}

32
cmd/status_windows.go Normal file
View file

@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/drakkan/sftpgo/service"
"github.com/spf13/cobra"
)
var (
statusCmd = &cobra.Command{
Use: "status",
Short: "Retrieve the status for the SFTPGo Windows Service",
Run: func(cmd *cobra.Command, args []string) {
s := service.WindowsService{
Service: service.Service{
Shutdown: make(chan bool),
},
}
status, err := s.Status()
if err != nil {
fmt.Printf("Error querying service status: %v\r\n", err)
} else {
fmt.Printf("Service status: %#v\r\n", status.String())
}
},
}
)
func init() {
serviceCmd.AddCommand(statusCmd)
}

32
cmd/stop_windows.go Normal file
View file

@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/drakkan/sftpgo/service"
"github.com/spf13/cobra"
)
var (
stopCmd = &cobra.Command{
Use: "stop",
Short: "Stop SFTPGo Windows Service",
Run: func(cmd *cobra.Command, args []string) {
s := service.WindowsService{
Service: service.Service{
Shutdown: make(chan bool),
},
}
err := s.Stop()
if err != nil {
fmt.Printf("Error stopping service: %v\r\n", err)
} else {
fmt.Printf("Service stopped!\r\n")
}
},
}
)
func init() {
serviceCmd.AddCommand(stopCmd)
}

32
cmd/uninstall_windows.go Normal file
View file

@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/drakkan/sftpgo/service"
"github.com/spf13/cobra"
)
var (
uninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Uninstall SFTPGo Windows Service",
Run: func(cmd *cobra.Command, args []string) {
s := service.WindowsService{
Service: service.Service{
Shutdown: make(chan bool),
},
}
err := s.Uninstall()
if err != nil {
fmt.Printf("Error removing service: %v\r\n", err)
} else {
fmt.Printf("Service uninstalled\r\n")
}
},
}
)
func init() {
serviceCmd.AddCommand(uninstallCmd)
}

209
config/config.go Normal file
View file

@ -0,0 +1,209 @@
// Package config manages the configuration.
// Configuration is loaded from sftpgo.conf file.
// If sftpgo.conf is not found or cannot be readed or decoded as json the default configuration is used.
// The default configuration an be found inside the source tree:
// https://github.com/drakkan/sftpgo/blob/master/sftpgo.conf
package config
import (
"fmt"
"strings"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/httpd"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/sftpd"
"github.com/drakkan/sftpgo/utils"
"github.com/spf13/viper"
)
const (
logSender = "config"
// DefaultConfigName defines the name for the default config file.
// This is the file name without extension, we use viper and so we
// support all the config files format supported by viper
DefaultConfigName = "sftpgo"
// ConfigEnvPrefix defines a prefix that ENVIRONMENT variables will use
configEnvPrefix = "sftpgo"
)
var (
globalConf globalConfig
defaultBanner = fmt.Sprintf("SFTPGo_%v", utils.GetAppVersion().Version)
)
type globalConfig struct {
SFTPD sftpd.Configuration `json:"sftpd" mapstructure:"sftpd"`
ProviderConf dataprovider.Config `json:"data_provider" mapstructure:"data_provider"`
HTTPDConfig httpd.Conf `json:"httpd" mapstructure:"httpd"`
}
func init() {
// create a default configuration to use if no config file is provided
globalConf = globalConfig{
SFTPD: sftpd.Configuration{
Banner: defaultBanner,
BindPort: 2022,
BindAddress: "",
IdleTimeout: 15,
MaxAuthTries: 0,
Umask: "0022",
UploadMode: 0,
Actions: sftpd.Actions{
ExecuteOn: []string{},
Command: "",
HTTPNotificationURL: "",
},
Keys: []sftpd.Key{},
IsSCPEnabled: false,
KexAlgorithms: []string{},
Ciphers: []string{},
MACs: []string{},
LoginBannerFile: "",
EnabledSSHCommands: sftpd.GetDefaultSSHCommands(),
KeyboardInteractiveProgram: "",
ProxyProtocol: 0,
ProxyAllowed: []string{},
},
ProviderConf: dataprovider.Config{
Driver: "sqlite",
Name: "sftpgo.db",
Host: "",
Port: 5432,
Username: "",
Password: "",
ConnectionString: "",
UsersTable: "users",
ManageUsers: 1,
SSLMode: 0,
TrackQuota: 1,
PoolSize: 0,
UsersBaseDir: "",
Actions: dataprovider.Actions{
ExecuteOn: []string{},
Command: "",
HTTPNotificationURL: "",
},
ExternalAuthProgram: "",
ExternalAuthScope: 0,
CredentialsPath: "credentials",
PreLoginProgram: "",
},
HTTPDConfig: httpd.Conf{
BindPort: 8080,
BindAddress: "127.0.0.1",
TemplatesPath: "templates",
StaticFilesPath: "static",
BackupsPath: "backups",
AuthUserFile: "",
CertificateFile: "",
CertificateKeyFile: "",
},
}
viper.SetEnvPrefix(configEnvPrefix)
replacer := strings.NewReplacer(".", "__")
viper.SetEnvKeyReplacer(replacer)
viper.SetConfigName(DefaultConfigName)
viper.AutomaticEnv()
viper.AllowEmptyEnv(true)
}
// GetSFTPDConfig returns the configuration for the SFTP server
func GetSFTPDConfig() sftpd.Configuration {
return globalConf.SFTPD
}
// SetSFTPDConfig sets the configuration for the SFTP server
func SetSFTPDConfig(config sftpd.Configuration) {
globalConf.SFTPD = config
}
// GetHTTPDConfig returns the configuration for the HTTP server
func GetHTTPDConfig() httpd.Conf {
return globalConf.HTTPDConfig
}
// SetHTTPDConfig sets the configuration for the HTTP server
func SetHTTPDConfig(config httpd.Conf) {
globalConf.HTTPDConfig = config
}
//GetProviderConf returns the configuration for the data provider
func GetProviderConf() dataprovider.Config {
return globalConf.ProviderConf
}
//SetProviderConf sets the configuration for the data provider
func SetProviderConf(config dataprovider.Config) {
globalConf.ProviderConf = config
}
func getRedactedGlobalConf() globalConfig {
conf := globalConf
conf.ProviderConf.Password = "[redacted]"
return conf
}
// LoadConfig loads the configuration
// configDir will be added to the configuration search paths.
// The search path contains by default the current directory and on linux it contains
// $HOME/.config/sftpgo and /etc/sftpgo too.
// configName is the name of the configuration to search without extension
func LoadConfig(configDir, configName string) error {
var err error
viper.AddConfigPath(configDir)
setViperAdditionalConfigPaths()
viper.AddConfigPath(".")
viper.SetConfigName(configName)
if err = viper.ReadInConfig(); err != nil {
logger.Warn(logSender, "", "error loading configuration file: %v. Default configuration will be used: %+v",
err, getRedactedGlobalConf())
logger.WarnToConsole("error loading configuration file: %v. Default configuration will be used.", err)
return err
}
err = viper.Unmarshal(&globalConf)
if err != nil {
logger.Warn(logSender, "", "error parsing configuration file: %v. Default configuration will be used: %+v",
err, getRedactedGlobalConf())
logger.WarnToConsole("error parsing configuration file: %v. Default configuration will be used.", err)
return err
}
if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
globalConf.SFTPD.Banner = defaultBanner
}
if len(globalConf.ProviderConf.UsersBaseDir) > 0 && !utils.IsFileInputValid(globalConf.ProviderConf.UsersBaseDir) {
err = fmt.Errorf("invalid users base dir %#v will be ignored", globalConf.ProviderConf.UsersBaseDir)
globalConf.ProviderConf.UsersBaseDir = ""
logger.Warn(logSender, "", "Configuration error: %v", err)
logger.WarnToConsole("Configuration error: %v", err)
}
if globalConf.SFTPD.UploadMode < 0 || globalConf.SFTPD.UploadMode > 2 {
err = fmt.Errorf("invalid upload_mode 0, 1 and 2 are supported, configured: %v reset upload_mode to 0",
globalConf.SFTPD.UploadMode)
globalConf.SFTPD.UploadMode = 0
logger.Warn(logSender, "", "Configuration error: %v", err)
logger.WarnToConsole("Configuration error: %v", err)
}
if globalConf.SFTPD.ProxyProtocol < 0 || globalConf.SFTPD.ProxyProtocol > 2 {
err = fmt.Errorf("invalid proxy_protocol 0, 1 and 2 are supported, configured: %v reset proxy_protocol to 0",
globalConf.SFTPD.ProxyProtocol)
globalConf.SFTPD.ProxyProtocol = 0
logger.Warn(logSender, "", "Configuration error: %v", err)
logger.WarnToConsole("Configuration error: %v", err)
}
if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 7 {
err = fmt.Errorf("invalid external_auth_scope: %v reset to 0", globalConf.ProviderConf.ExternalAuthScope)
globalConf.ProviderConf.ExternalAuthScope = 0
logger.Warn(logSender, "", "Configuration error: %v", err)
logger.WarnToConsole("Configuration error: %v", err)
}
if len(globalConf.ProviderConf.CredentialsPath) == 0 {
err = fmt.Errorf("invalid credentials path, reset to \"credentials\"")
globalConf.ProviderConf.CredentialsPath = "credentials"
logger.Warn(logSender, "", "Configuration error: %v", err)
logger.WarnToConsole("Configuration error: %v", err)
}
logger.Debug(logSender, "", "config file used: '%#v', config loaded: %+v", viper.ConfigFileUsed(), getRedactedGlobalConf())
return err
}

11
config/config_linux.go Normal file
View file

@ -0,0 +1,11 @@
// +build linux
package config
import "github.com/spf13/viper"
// linux specific config search path
func setViperAdditionalConfigPaths() {
viper.AddConfigPath("$HOME/.config/sftpgo")
viper.AddConfigPath("/etc/sftpgo")
}

7
config/config_nolinux.go Normal file
View file

@ -0,0 +1,7 @@
// +build !linux
package config
func setViperAdditionalConfigPaths() {
}

204
config/config_test.go Normal file
View file

@ -0,0 +1,204 @@
package config_test
import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/drakkan/sftpgo/config"
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/httpd"
"github.com/drakkan/sftpgo/sftpd"
)
const (
tempConfigName = "temp"
)
func TestLoadConfigTest(t *testing.T) {
configDir := ".."
err := config.LoadConfig(configDir, "")
if err != nil {
t.Errorf("error loading config")
}
emptyHTTPDConf := httpd.Conf{}
if config.GetHTTPDConfig() == emptyHTTPDConf {
t.Errorf("error loading httpd conf")
}
emptyProviderConf := dataprovider.Config{}
if config.GetProviderConf().Driver == emptyProviderConf.Driver {
t.Errorf("error loading provider conf")
}
emptySFTPDConf := sftpd.Configuration{}
if config.GetSFTPDConfig().BindPort == emptySFTPDConf.BindPort {
t.Errorf("error loading SFTPD conf")
}
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("loading a non existent config file must fail")
}
ioutil.WriteFile(configFilePath, []byte("{invalid json}"), 0666)
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("loading an invalid config file must fail")
}
ioutil.WriteFile(configFilePath, []byte("{\"sftpd\": {\"bind_port\": \"a\"}}"), 0666)
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("loading a config with an invalid bond_port must fail")
}
os.Remove(configFilePath)
}
func TestEmptyBanner(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
sftpdConf := config.GetSFTPDConfig()
sftpdConf.Banner = " "
c := make(map[string]sftpd.Configuration)
c["sftpd"] = sftpdConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
config.LoadConfig(configDir, tempConfigName)
sftpdConf = config.GetSFTPDConfig()
if strings.TrimSpace(sftpdConf.Banner) == "" {
t.Errorf("SFTPD banner cannot be empty")
}
os.Remove(configFilePath)
}
func TestInvalidUploadMode(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
sftpdConf := config.GetSFTPDConfig()
sftpdConf.UploadMode = 10
c := make(map[string]sftpd.Configuration)
c["sftpd"] = sftpdConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("Loading configuration with invalid upload_mode must fail")
}
os.Remove(configFilePath)
}
func TestInvalidExternalAuthScope(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
providerConf := config.GetProviderConf()
providerConf.ExternalAuthScope = 10
c := make(map[string]dataprovider.Config)
c["data_provider"] = providerConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("Loading configuration with invalid external_auth_scope must fail")
}
os.Remove(configFilePath)
}
func TestInvalidCredentialsPath(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
providerConf := config.GetProviderConf()
providerConf.CredentialsPath = ""
c := make(map[string]dataprovider.Config)
c["data_provider"] = providerConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("Loading configuration with credentials path must fail")
}
os.Remove(configFilePath)
}
func TestInvalidProxyProtocol(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
sftpdConf := config.GetSFTPDConfig()
sftpdConf.ProxyProtocol = 10
c := make(map[string]sftpd.Configuration)
c["sftpd"] = sftpdConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("Loading configuration with invalid proxy_protocol must fail")
}
os.Remove(configFilePath)
}
func TestInvalidUsersBaseDir(t *testing.T) {
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
config.LoadConfig(configDir, "")
providerConf := config.GetProviderConf()
providerConf.UsersBaseDir = "."
c := make(map[string]dataprovider.Config)
c["data_provider"] = providerConf
jsonConf, _ := json.Marshal(c)
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
if err != nil {
t.Errorf("error saving temporary configuration")
}
err = config.LoadConfig(configDir, tempConfigName)
if err == nil {
t.Errorf("Loading configuration with invalid users base dir must fail")
}
os.Remove(configFilePath)
}
func TestSetGetConfig(t *testing.T) {
sftpdConf := config.GetSFTPDConfig()
sftpdConf.IdleTimeout = 3
config.SetSFTPDConfig(sftpdConf)
if config.GetSFTPDConfig().IdleTimeout != sftpdConf.IdleTimeout {
t.Errorf("set sftpd conf failed")
}
dataProviderConf := config.GetProviderConf()
dataProviderConf.Host = "test host"
config.SetProviderConf(dataProviderConf)
if config.GetProviderConf().Host != dataProviderConf.Host {
t.Errorf("set data provider conf failed")
}
httpdConf := config.GetHTTPDConfig()
httpdConf.BindAddress = "0.0.0.0"
config.SetHTTPDConfig(httpdConf)
if config.GetHTTPDConfig().BindAddress != httpdConf.BindAddress {
t.Errorf("set httpd conf failed")
}
}

View file

@ -1,6 +0,0 @@
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
files:
- source: /static/locales/en/translation.json
translation: /static/locales/%two_letters_code%/%original_file_name%
type: i18next_json

568
dataprovider/bolt.go Normal file
View file

@ -0,0 +1,568 @@
package dataprovider
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
bolt "go.etcd.io/bbolt"
)
const (
boltDatabaseVersion = 3
)
var (
usersBucket = []byte("users")
usersIDIdxBucket = []byte("users_id_idx")
dbVersionBucket = []byte("db_version")
dbVersionKey = []byte("version")
)
// BoltProvider auth provider for bolt key/value store
type BoltProvider struct {
dbHandle *bolt.DB
}
type compatUserV2 struct {
ID int64 `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
PublicKeys []string `json:"public_keys,omitempty"`
HomeDir string `json:"home_dir"`
UID int `json:"uid"`
GID int `json:"gid"`
MaxSessions int `json:"max_sessions"`
QuotaSize int64 `json:"quota_size"`
QuotaFiles int `json:"quota_files"`
Permissions []string `json:"permissions"`
UsedQuotaSize int64 `json:"used_quota_size"`
UsedQuotaFiles int `json:"used_quota_files"`
LastQuotaUpdate int64 `json:"last_quota_update"`
UploadBandwidth int64 `json:"upload_bandwidth"`
DownloadBandwidth int64 `json:"download_bandwidth"`
ExpirationDate int64 `json:"expiration_date"`
LastLogin int64 `json:"last_login"`
Status int `json:"status"`
}
func initializeBoltProvider(basePath string) error {
var err error
logSender = BoltDataProviderName
dbPath := config.Name
if !utils.IsFileInputValid(dbPath) {
return fmt.Errorf("Invalid database path: %#v", dbPath)
}
if !filepath.IsAbs(dbPath) {
dbPath = filepath.Join(basePath, dbPath)
}
dbHandle, err := bolt.Open(dbPath, 0600, &bolt.Options{
NoGrowSync: false,
FreelistType: bolt.FreelistArrayType,
Timeout: 5 * time.Second})
if err == nil {
providerLog(logger.LevelDebug, "bolt key store handle created")
err = dbHandle.Update(func(tx *bolt.Tx) error {
_, e := tx.CreateBucketIfNotExists(usersBucket)
return e
})
if err != nil {
providerLog(logger.LevelWarn, "error creating users bucket: %v", err)
return err
}
err = dbHandle.Update(func(tx *bolt.Tx) error {
_, e := tx.CreateBucketIfNotExists(usersIDIdxBucket)
return e
})
if err != nil {
providerLog(logger.LevelWarn, "error creating username idx bucket: %v", err)
return err
}
err = dbHandle.Update(func(tx *bolt.Tx) error {
_, e := tx.CreateBucketIfNotExists(dbVersionBucket)
return e
})
if err != nil {
providerLog(logger.LevelWarn, "error creating database version bucket: %v", err)
return err
}
provider = BoltProvider{dbHandle: dbHandle}
} else {
providerLog(logger.LevelWarn, "error creating bolt key/value store handler: %v", err)
}
return err
}
func (p BoltProvider) checkAvailability() error {
_, err := p.getUsers(1, 0, "ASC", "")
return err
}
func (p BoltProvider) validateUserAndPass(username string, password string) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
}
user, err := p.userExists(username)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
}
func (p BoltProvider) validateUserAndPubKey(username string, pubKey string) (User, string, error) {
var user User
if len(pubKey) == 0 {
return user, "", errors.New("Credentials cannot be null or empty")
}
user, err := p.userExists(username)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, "", err
}
return checkUserAndPubKey(user, pubKey)
}
func (p BoltProvider) getUserByID(ID int64) (User, error) {
var user User
err := p.dbHandle.View(func(tx *bolt.Tx) error {
bucket, idxBucket, err := getBuckets(tx)
if err != nil {
return err
}
userIDAsBytes := itob(ID)
username := idxBucket.Get(userIDAsBytes)
if username == nil {
return &RecordNotFoundError{err: fmt.Sprintf("user with ID %v does not exist", ID)}
}
u := bucket.Get(username)
if u == nil {
return &RecordNotFoundError{err: fmt.Sprintf("username %#v and ID: %v does not exist", string(username), ID)}
}
return json.Unmarshal(u, &user)
})
return user, err
}
func (p BoltProvider) updateLastLogin(username string) error {
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
var u []byte
if u = bucket.Get([]byte(username)); u == nil {
return &RecordNotFoundError{err: fmt.Sprintf("username %#v does not exist, unable to update last login", username)}
}
var user User
err = json.Unmarshal(u, &user)
if err != nil {
return err
}
user.LastLogin = utils.GetTimeAsMsSinceEpoch(time.Now())
buf, err := json.Marshal(user)
if err != nil {
return err
}
return bucket.Put([]byte(username), buf)
})
}
func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
var u []byte
if u = bucket.Get([]byte(username)); u == nil {
return &RecordNotFoundError{err: fmt.Sprintf("username %#v does not exist, unable to update quota", username)}
}
var user User
err = json.Unmarshal(u, &user)
if err != nil {
return err
}
if reset {
user.UsedQuotaSize = sizeAdd
user.UsedQuotaFiles = filesAdd
} else {
user.UsedQuotaSize += sizeAdd
user.UsedQuotaFiles += filesAdd
}
user.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now())
buf, err := json.Marshal(user)
if err != nil {
return err
}
return bucket.Put([]byte(username), buf)
})
}
func (p BoltProvider) getUsedQuota(username string) (int, int64, error) {
user, err := p.userExists(username)
if err != nil {
providerLog(logger.LevelWarn, "unable to get quota for user %v error: %v", username, err)
return 0, 0, err
}
return user.UsedQuotaFiles, user.UsedQuotaSize, err
}
func (p BoltProvider) userExists(username string) (User, error) {
var user User
err := p.dbHandle.View(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
u := bucket.Get([]byte(username))
if u == nil {
return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", username)}
}
return json.Unmarshal(u, &user)
})
return user, err
}
func (p BoltProvider) addUser(user User) error {
err := validateUser(&user)
if err != nil {
return err
}
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, idxBucket, err := getBuckets(tx)
if err != nil {
return err
}
if u := bucket.Get([]byte(user.Username)); u != nil {
return fmt.Errorf("username %v already exists", user.Username)
}
id, err := bucket.NextSequence()
if err != nil {
return err
}
user.ID = int64(id)
buf, err := json.Marshal(user)
if err != nil {
return err
}
userIDAsBytes := itob(user.ID)
err = bucket.Put([]byte(user.Username), buf)
if err != nil {
return err
}
return idxBucket.Put(userIDAsBytes, []byte(user.Username))
})
}
func (p BoltProvider) updateUser(user User) error {
err := validateUser(&user)
if err != nil {
return err
}
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
if u := bucket.Get([]byte(user.Username)); u == nil {
return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", user.Username)}
}
buf, err := json.Marshal(user)
if err != nil {
return err
}
return bucket.Put([]byte(user.Username), buf)
})
}
func (p BoltProvider) deleteUser(user User) error {
return p.dbHandle.Update(func(tx *bolt.Tx) error {
bucket, idxBucket, err := getBuckets(tx)
if err != nil {
return err
}
userIDAsBytes := itob(user.ID)
userName := idxBucket.Get(userIDAsBytes)
if userName == nil {
return &RecordNotFoundError{err: fmt.Sprintf("user with id %v does not exist", user.ID)}
}
err = bucket.Delete(userName)
if err != nil {
return err
}
return idxBucket.Delete(userIDAsBytes)
})
}
func (p BoltProvider) dumpUsers() ([]User, error) {
users := []User{}
var err error
err = p.dbHandle.View(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var user User
err = json.Unmarshal(v, &user)
if err != nil {
return err
}
err = addCredentialsToUser(&user)
if err != nil {
return err
}
users = append(users, user)
}
return err
})
return users, err
}
func (p BoltProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
users := []User{}
var err error
if limit <= 0 {
return users, err
}
if len(username) > 0 {
if offset == 0 {
user, err := p.userExists(username)
if err == nil {
users = append(users, HideUserSensitiveData(&user))
}
}
return users, err
}
err = p.dbHandle.View(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
cursor := bucket.Cursor()
itNum := 0
if order == "ASC" {
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
itNum++
if itNum <= offset {
continue
}
var user User
err = json.Unmarshal(v, &user)
if err == nil {
users = append(users, HideUserSensitiveData(&user))
}
if len(users) >= limit {
break
}
}
} else {
for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() {
itNum++
if itNum <= offset {
continue
}
var user User
err = json.Unmarshal(v, &user)
if err == nil {
users = append(users, HideUserSensitiveData(&user))
}
if len(users) >= limit {
break
}
}
}
return err
})
return users, err
}
func (p BoltProvider) close() error {
return p.dbHandle.Close()
}
func (p BoltProvider) reloadConfig() error {
return nil
}
// initializeDatabase does nothing, no initilization is needed for bolt provider
func (p BoltProvider) initializeDatabase() error {
return errNoInitRequired
}
func (p BoltProvider) migrateDatabase() error {
dbVersion, err := getBoltDatabaseVersion(p.dbHandle)
if err != nil {
return err
}
if dbVersion.Version == boltDatabaseVersion {
providerLog(logger.LevelDebug, "bolt database is updated, current version: %v", dbVersion.Version)
return nil
}
if dbVersion.Version == 1 {
err = updateDatabaseFrom1To2(p.dbHandle)
if err != nil {
return err
}
return updateDatabaseFrom2To3(p.dbHandle)
} else if dbVersion.Version == 2 {
return updateDatabaseFrom2To3(p.dbHandle)
}
return nil
}
// itob returns an 8-byte big endian representation of v.
func itob(v int64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(v))
return b
}
func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) {
var err error
bucket := tx.Bucket(usersBucket)
idxBucket := tx.Bucket(usersIDIdxBucket)
if bucket == nil || idxBucket == nil {
err = fmt.Errorf("unable to find required buckets, bolt database structure not correcly defined")
}
return bucket, idxBucket, err
}
func updateDatabaseFrom1To2(dbHandle *bolt.DB) error {
providerLog(logger.LevelInfo, "updating bolt database version: 1 -> 2")
usernames, err := getBoltAvailableUsernames(dbHandle)
if err != nil {
return err
}
for _, u := range usernames {
user, err := provider.userExists(u)
if err != nil {
return err
}
user.Status = 1
err = provider.updateUser(user)
if err != nil {
return err
}
providerLog(logger.LevelInfo, "user %#v updated, \"status\" setted to 1", user.Username)
}
return updateBoltDatabaseVersion(dbHandle, 2)
}
func updateDatabaseFrom2To3(dbHandle *bolt.DB) error {
providerLog(logger.LevelInfo, "updating bolt database version: 2 -> 3")
users := []User{}
err := dbHandle.View(func(tx *bolt.Tx) error {
bucket, _, err := getBuckets(tx)
if err != nil {
return err
}
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var compatUser compatUserV2
err = json.Unmarshal(v, &compatUser)
if err == nil {
user := User{}
user.ID = compatUser.ID
user.Username = compatUser.Username
user.Password = compatUser.Password
user.PublicKeys = compatUser.PublicKeys
user.HomeDir = compatUser.HomeDir
user.UID = compatUser.UID
user.GID = compatUser.GID
user.MaxSessions = compatUser.MaxSessions
user.QuotaSize = compatUser.QuotaSize
user.QuotaFiles = compatUser.QuotaFiles
user.Permissions = make(map[string][]string)
user.Permissions["/"] = compatUser.Permissions
user.UsedQuotaSize = compatUser.UsedQuotaSize
user.UsedQuotaFiles = compatUser.UsedQuotaFiles
user.LastQuotaUpdate = compatUser.LastQuotaUpdate
user.UploadBandwidth = compatUser.UploadBandwidth
user.DownloadBandwidth = compatUser.DownloadBandwidth
user.ExpirationDate = compatUser.ExpirationDate
user.LastLogin = compatUser.LastLogin
user.Status = compatUser.Status
users = append(users, user)
}
}
return err
})
if err != nil {
return err
}
for _, user := range users {
err = provider.updateUser(user)
if err != nil {
return err
}
providerLog(logger.LevelInfo, "user %#v updated, \"permissions\" setted to %+v", user.Username, user.Permissions)
}
return updateBoltDatabaseVersion(dbHandle, 3)
}
func getBoltAvailableUsernames(dbHandle *bolt.DB) ([]string, error) {
usernames := []string{}
err := dbHandle.View(func(tx *bolt.Tx) error {
_, idxBucket, err := getBuckets(tx)
if err != nil {
return err
}
cursor := idxBucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
usernames = append(usernames, string(v))
}
return nil
})
return usernames, err
}
func getBoltDatabaseVersion(dbHandle *bolt.DB) (schemaVersion, error) {
var dbVersion schemaVersion
err := dbHandle.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(dbVersionBucket)
if bucket == nil {
return fmt.Errorf("unable to find database version bucket")
}
v := bucket.Get(dbVersionKey)
if v == nil {
dbVersion = schemaVersion{
Version: 1,
}
return nil
}
return json.Unmarshal(v, &dbVersion)
})
return dbVersion, err
}
func updateBoltDatabaseVersion(dbHandle *bolt.DB, version int) error {
err := dbHandle.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(dbVersionBucket)
if bucket == nil {
return fmt.Errorf("unable to find database version bucket")
}
newDbVersion := schemaVersion{
Version: version,
}
buf, err := json.Marshal(newDbVersion)
if err != nil {
return err
}
return bucket.Put(dbVersionKey, buf)
})
return err
}

1342
dataprovider/dataprovider.go Normal file

File diff suppressed because it is too large Load diff

401
dataprovider/memory.go Normal file
View file

@ -0,0 +1,401 @@
package dataprovider
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
)
var (
errMemoryProviderClosed = errors.New("memory provider is closed")
)
type memoryProviderHandle struct {
isClosed bool
// slice with ordered usernames
usernames []string
// mapping between ID and username
usersIdx map[int64]string
// map for users, username is the key
users map[string]User
// configuration file to use for loading users
configFile string
lock *sync.Mutex
}
// MemoryProvider auth provider for a memory store
type MemoryProvider struct {
dbHandle *memoryProviderHandle
}
func initializeMemoryProvider(basePath string) error {
configFile := ""
if utils.IsFileInputValid(config.Name) {
configFile = config.Name
if !filepath.IsAbs(configFile) {
configFile = filepath.Join(basePath, configFile)
}
}
provider = MemoryProvider{
dbHandle: &memoryProviderHandle{
isClosed: false,
usernames: []string{},
usersIdx: make(map[int64]string),
users: make(map[string]User),
configFile: configFile,
lock: new(sync.Mutex),
},
}
return provider.reloadConfig()
}
func (p MemoryProvider) checkAvailability() error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
return nil
}
func (p MemoryProvider) close() error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
p.dbHandle.isClosed = true
return nil
}
func (p MemoryProvider) validateUserAndPass(username string, password string) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
}
user, err := p.userExists(username)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
}
func (p MemoryProvider) validateUserAndPubKey(username string, pubKey string) (User, string, error) {
var user User
if len(pubKey) == 0 {
return user, "", errors.New("Credentials cannot be null or empty")
}
user, err := p.userExists(username)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, "", err
}
return checkUserAndPubKey(user, pubKey)
}
func (p MemoryProvider) getUserByID(ID int64) (User, error) {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return User{}, errMemoryProviderClosed
}
if val, ok := p.dbHandle.usersIdx[ID]; ok {
return p.userExistsInternal(val)
}
return User{}, &RecordNotFoundError{err: fmt.Sprintf("user with ID %v does not exist", ID)}
}
func (p MemoryProvider) updateLastLogin(username string) error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
user, err := p.userExistsInternal(username)
if err != nil {
return err
}
user.LastLogin = utils.GetTimeAsMsSinceEpoch(time.Now())
p.dbHandle.users[user.Username] = user
return nil
}
func (p MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
user, err := p.userExistsInternal(username)
if err != nil {
providerLog(logger.LevelWarn, "unable to update quota for user %v error: %v", username, err)
return err
}
if reset {
user.UsedQuotaSize = sizeAdd
user.UsedQuotaFiles = filesAdd
} else {
user.UsedQuotaSize += sizeAdd
user.UsedQuotaFiles += filesAdd
}
user.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now())
p.dbHandle.users[user.Username] = user
return nil
}
func (p MemoryProvider) getUsedQuota(username string) (int, int64, error) {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return 0, 0, errMemoryProviderClosed
}
user, err := p.userExistsInternal(username)
if err != nil {
providerLog(logger.LevelWarn, "unable to get quota for user %v error: %v", username, err)
return 0, 0, err
}
return user.UsedQuotaFiles, user.UsedQuotaSize, err
}
func (p MemoryProvider) addUser(user User) error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
err := validateUser(&user)
if err != nil {
return err
}
_, err = p.userExistsInternal(user.Username)
if err == nil {
return fmt.Errorf("username %v already exists", user.Username)
}
user.ID = p.getNextID()
p.dbHandle.users[user.Username] = user
p.dbHandle.usersIdx[user.ID] = user.Username
p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username)
sort.Strings(p.dbHandle.usernames)
return nil
}
func (p MemoryProvider) updateUser(user User) error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
err := validateUser(&user)
if err != nil {
return err
}
_, err = p.userExistsInternal(user.Username)
if err != nil {
return err
}
p.dbHandle.users[user.Username] = user
return nil
}
func (p MemoryProvider) deleteUser(user User) error {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return errMemoryProviderClosed
}
_, err := p.userExistsInternal(user.Username)
if err != nil {
return err
}
delete(p.dbHandle.users, user.Username)
delete(p.dbHandle.usersIdx, user.ID)
// this could be more efficient
p.dbHandle.usernames = []string{}
for username := range p.dbHandle.users {
p.dbHandle.usernames = append(p.dbHandle.usernames, username)
}
sort.Strings(p.dbHandle.usernames)
return nil
}
func (p MemoryProvider) dumpUsers() ([]User, error) {
users := []User{}
var err error
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return users, errMemoryProviderClosed
}
for _, username := range p.dbHandle.usernames {
user := p.dbHandle.users[username]
err = addCredentialsToUser(&user)
if err != nil {
return users, err
}
users = append(users, user)
}
return users, err
}
func (p MemoryProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
users := []User{}
var err error
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return users, errMemoryProviderClosed
}
if limit <= 0 {
return users, err
}
if len(username) > 0 {
if offset == 0 {
user, err := p.userExistsInternal(username)
if err == nil {
users = append(users, HideUserSensitiveData(&user))
}
}
return users, err
}
itNum := 0
if order == "ASC" {
for _, username := range p.dbHandle.usernames {
itNum++
if itNum <= offset {
continue
}
user := p.dbHandle.users[username]
users = append(users, HideUserSensitiveData(&user))
if len(users) >= limit {
break
}
}
} else {
for i := len(p.dbHandle.usernames) - 1; i >= 0; i-- {
itNum++
if itNum <= offset {
continue
}
username := p.dbHandle.usernames[i]
user := p.dbHandle.users[username]
users = append(users, HideUserSensitiveData(&user))
if len(users) >= limit {
break
}
}
}
return users, err
}
func (p MemoryProvider) userExists(username string) (User, error) {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
if p.dbHandle.isClosed {
return User{}, errMemoryProviderClosed
}
return p.userExistsInternal(username)
}
func (p MemoryProvider) userExistsInternal(username string) (User, error) {
if val, ok := p.dbHandle.users[username]; ok {
return val.getACopy(), nil
}
return User{}, &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", username)}
}
func (p MemoryProvider) getNextID() int64 {
nextID := int64(1)
for id := range p.dbHandle.usersIdx {
if id >= nextID {
nextID = id + 1
}
}
return nextID
}
func (p MemoryProvider) clearUsers() {
p.dbHandle.lock.Lock()
defer p.dbHandle.lock.Unlock()
p.dbHandle.usernames = []string{}
p.dbHandle.usersIdx = make(map[int64]string)
p.dbHandle.users = make(map[string]User)
}
func (p MemoryProvider) reloadConfig() error {
if len(p.dbHandle.configFile) == 0 {
providerLog(logger.LevelDebug, "no users configuration file defined")
return nil
}
providerLog(logger.LevelDebug, "loading users from file: %#v", p.dbHandle.configFile)
fi, err := os.Stat(p.dbHandle.configFile)
if err != nil {
providerLog(logger.LevelWarn, "error loading users: %v", err)
return err
}
if fi.Size() == 0 {
err = errors.New("users configuration file is invalid, its size must be > 0")
providerLog(logger.LevelWarn, "error loading users: %v", err)
return err
}
if fi.Size() > 10485760 {
err = errors.New("users configuration file is invalid, its size must be <= 10485760 bytes")
providerLog(logger.LevelWarn, "error loading users: %v", err)
return err
}
content, err := ioutil.ReadFile(p.dbHandle.configFile)
if err != nil {
providerLog(logger.LevelWarn, "error loading users: %v", err)
return err
}
var dump BackupData
err = json.Unmarshal(content, &dump)
if err != nil {
providerLog(logger.LevelWarn, "error loading users: %v", err)
return err
}
p.clearUsers()
for _, user := range dump.Users {
u, err := p.userExists(user.Username)
if err == nil {
user.ID = u.ID
user.LastLogin = u.LastLogin
user.UsedQuotaSize = u.UsedQuotaSize
user.UsedQuotaFiles = u.UsedQuotaFiles
err = p.updateUser(user)
if err != nil {
providerLog(logger.LevelWarn, "error updating user %#v: %v", user.Username, err)
return err
}
} else {
user.LastLogin = 0
user.UsedQuotaSize = 0
user.UsedQuotaFiles = 0
err = p.addUser(user)
if err != nil {
providerLog(logger.LevelWarn, "error adding user %#v: %v", user.Username, err)
return err
}
}
}
providerLog(logger.LevelDebug, "users loaded from file: %#v", p.dbHandle.configFile)
return nil
}
// initializeDatabase does nothing, no initilization is needed for memory provider
func (p MemoryProvider) initializeDatabase() error {
return errNoInitRequired
}
func (p MemoryProvider) migrateDatabase() error {
return nil
}

179
dataprovider/mysql.go Normal file
View file

@ -0,0 +1,179 @@
package dataprovider
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/drakkan/sftpgo/logger"
)
const (
mysqlUsersTableSQL = "CREATE TABLE `{{users}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
"`username` varchar(255) NOT NULL UNIQUE, `password` varchar(255) NULL, `public_keys` longtext NULL, " +
"`home_dir` varchar(255) NOT NULL, `uid` integer NOT NULL, `gid` integer NOT NULL, `max_sessions` integer NOT NULL, " +
" `quota_size` bigint NOT NULL, `quota_files` integer NOT NULL, `permissions` longtext NOT NULL, " +
"`used_quota_size` bigint NOT NULL, `used_quota_files` integer NOT NULL, `last_quota_update` bigint NOT NULL, " +
"`upload_bandwidth` integer NOT NULL, `download_bandwidth` integer NOT NULL, `expiration_date` bigint(20) NOT NULL, " +
"`last_login` bigint(20) NOT NULL, `status` int(11) NOT NULL, `filters` longtext DEFAULT NULL, " +
"`filesystem` longtext DEFAULT NULL);"
mysqlSchemaTableSQL = "CREATE TABLE `schema_version` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);"
mysqlUsersV2SQL = "ALTER TABLE `{{users}}` ADD COLUMN `virtual_folders` longtext NULL;"
)
// MySQLProvider auth provider for MySQL/MariaDB database
type MySQLProvider struct {
dbHandle *sql.DB
}
func initializeMySQLProvider() error {
var err error
logSender = MySQLDataProviderName
dbHandle, err := sql.Open("mysql", getMySQLConnectionString(false))
if err == nil {
providerLog(logger.LevelDebug, "mysql database handle created, connection string: %#v, pool size: %v",
getMySQLConnectionString(true), config.PoolSize)
dbHandle.SetMaxOpenConns(config.PoolSize)
dbHandle.SetConnMaxLifetime(1800 * time.Second)
provider = MySQLProvider{dbHandle: dbHandle}
} else {
providerLog(logger.LevelWarn, "error creating mysql database handler, connection string: %#v, error: %v",
getMySQLConnectionString(true), err)
}
return err
}
func getMySQLConnectionString(redactedPwd bool) string {
var connectionString string
if len(config.ConnectionString) == 0 {
password := config.Password
if redactedPwd {
password = "[redacted]"
}
connectionString = fmt.Sprintf("%v:%v@tcp([%v]:%v)/%v?charset=utf8&interpolateParams=true&timeout=10s&tls=%v&writeTimeout=10s&readTimeout=10s",
config.Username, password, config.Host, config.Port, config.Name, getSSLMode())
} else {
connectionString = config.ConnectionString
}
return connectionString
}
func (p MySQLProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p MySQLProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
}
func (p MySQLProvider) validateUserAndPubKey(username string, publicKey string) (User, string, error) {
return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
}
func (p MySQLProvider) getUserByID(ID int64) (User, error) {
return sqlCommonGetUserByID(ID, p.dbHandle)
}
func (p MySQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
}
func (p MySQLProvider) updateLastLogin(username string) error {
return sqlCommonUpdateLastLogin(username, p.dbHandle)
}
func (p MySQLProvider) getUsedQuota(username string) (int, int64, error) {
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
func (p MySQLProvider) userExists(username string) (User, error) {
return sqlCommonCheckUserExists(username, p.dbHandle)
}
func (p MySQLProvider) addUser(user User) error {
return sqlCommonAddUser(user, p.dbHandle)
}
func (p MySQLProvider) updateUser(user User) error {
return sqlCommonUpdateUser(user, p.dbHandle)
}
func (p MySQLProvider) deleteUser(user User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p MySQLProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}
func (p MySQLProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
}
func (p MySQLProvider) close() error {
return p.dbHandle.Close()
}
func (p MySQLProvider) reloadConfig() error {
return nil
}
// initializeDatabase creates the initial database structure
func (p MySQLProvider) initializeDatabase() error {
sqlUsers := strings.Replace(mysqlUsersTableSQL, "{{users}}", config.UsersTable, 1)
tx, err := p.dbHandle.Begin()
if err != nil {
return err
}
_, err = tx.Exec(sqlUsers)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(mysqlSchemaTableSQL)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(initialDBVersionSQL)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
func (p MySQLProvider) migrateDatabase() error {
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
if err != nil {
return err
}
if dbVersion.Version == sqlDatabaseVersion {
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
return nil
}
if dbVersion.Version == 1 {
return updateMySQLDatabaseFrom1To2(p.dbHandle)
}
return nil
}
func updateMySQLDatabaseFrom1To2(dbHandle *sql.DB) error {
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
sql := strings.Replace(mysqlUsersV2SQL, "{{users}}", config.UsersTable, 1)
tx, err := dbHandle.Begin()
if err != nil {
return err
}
_, err = tx.Exec(sql)
if err != nil {
tx.Rollback()
return err
}
err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}

177
dataprovider/pgsql.go Normal file
View file

@ -0,0 +1,177 @@
package dataprovider
import (
"database/sql"
"fmt"
"strings"
"github.com/drakkan/sftpgo/logger"
)
const (
pgsqlUsersTableSQL = `CREATE TABLE "{{users}}" ("id" serial NOT NULL PRIMARY KEY, "username" varchar(255) NOT NULL UNIQUE,
"password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL,
"gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
"permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL,
"last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL,
"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
"filesystem" text NULL);`
pgsqlSchemaTableSQL = `CREATE TABLE "schema_version" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL);`
pgsqlUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;`
)
// PGSQLProvider auth provider for PostgreSQL database
type PGSQLProvider struct {
dbHandle *sql.DB
}
func initializePGSQLProvider() error {
var err error
logSender = PGSQLDataProviderName
dbHandle, err := sql.Open("postgres", getPGSQLConnectionString(false))
if err == nil {
providerLog(logger.LevelDebug, "postgres database handle created, connection string: %#v, pool size: %v",
getPGSQLConnectionString(true), config.PoolSize)
dbHandle.SetMaxOpenConns(config.PoolSize)
provider = PGSQLProvider{dbHandle: dbHandle}
} else {
providerLog(logger.LevelWarn, "error creating postgres database handler, connection string: %#v, error: %v",
getPGSQLConnectionString(true), err)
}
return err
}
func getPGSQLConnectionString(redactedPwd bool) string {
var connectionString string
if len(config.ConnectionString) == 0 {
password := config.Password
if redactedPwd {
password = "[redacted]"
}
connectionString = fmt.Sprintf("host='%v' port=%v dbname='%v' user='%v' password='%v' sslmode=%v connect_timeout=10",
config.Host, config.Port, config.Name, config.Username, password, getSSLMode())
} else {
connectionString = config.ConnectionString
}
return connectionString
}
func (p PGSQLProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p PGSQLProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
}
func (p PGSQLProvider) validateUserAndPubKey(username string, publicKey string) (User, string, error) {
return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
}
func (p PGSQLProvider) getUserByID(ID int64) (User, error) {
return sqlCommonGetUserByID(ID, p.dbHandle)
}
func (p PGSQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
}
func (p PGSQLProvider) updateLastLogin(username string) error {
return sqlCommonUpdateLastLogin(username, p.dbHandle)
}
func (p PGSQLProvider) getUsedQuota(username string) (int, int64, error) {
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
func (p PGSQLProvider) userExists(username string) (User, error) {
return sqlCommonCheckUserExists(username, p.dbHandle)
}
func (p PGSQLProvider) addUser(user User) error {
return sqlCommonAddUser(user, p.dbHandle)
}
func (p PGSQLProvider) updateUser(user User) error {
return sqlCommonUpdateUser(user, p.dbHandle)
}
func (p PGSQLProvider) deleteUser(user User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p PGSQLProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}
func (p PGSQLProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
}
func (p PGSQLProvider) close() error {
return p.dbHandle.Close()
}
func (p PGSQLProvider) reloadConfig() error {
return nil
}
// initializeDatabase creates the initial database structure
func (p PGSQLProvider) initializeDatabase() error {
sqlUsers := strings.Replace(pgsqlUsersTableSQL, "{{users}}", config.UsersTable, 1)
tx, err := p.dbHandle.Begin()
if err != nil {
return err
}
_, err = tx.Exec(sqlUsers)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(pgsqlSchemaTableSQL)
if err != nil {
tx.Rollback()
return err
}
_, err = tx.Exec(initialDBVersionSQL)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
func (p PGSQLProvider) migrateDatabase() error {
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
if err != nil {
return err
}
if dbVersion.Version == sqlDatabaseVersion {
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
return nil
}
if dbVersion.Version == 1 {
return updatePGSQLDatabaseFrom1To2(p.dbHandle)
}
return nil
}
func updatePGSQLDatabaseFrom1To2(dbHandle *sql.DB) error {
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
sql := strings.Replace(pgsqlUsersV2SQL, "{{users}}", config.UsersTable, 1)
tx, err := dbHandle.Begin()
if err != nil {
return err
}
_, err = tx.Exec(sql)
if err != nil {
tx.Rollback()
return err
}
err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2)
if err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}

418
dataprovider/sqlcommon.go Normal file
View file

@ -0,0 +1,418 @@
package dataprovider
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
)
const (
sqlDatabaseVersion = 2
initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);"
)
func getUserByUsername(username string, dbHandle *sql.DB) (User, error) {
var user User
q := getUserByUsernameQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return user, err
}
defer stmt.Close()
row := stmt.QueryRow(username)
return getUserFromDbRow(row, nil)
}
func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sql.DB) (User, error) {
var user User
if len(password) == 0 {
return user, errors.New("Credentials cannot be null or empty")
}
user, err := getUserByUsername(username, dbHandle)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, err
}
return checkUserAndPass(user, password)
}
func sqlCommonValidateUserAndPubKey(username string, pubKey string, dbHandle *sql.DB) (User, string, error) {
var user User
if len(pubKey) == 0 {
return user, "", errors.New("Credentials cannot be null or empty")
}
user, err := getUserByUsername(username, dbHandle)
if err != nil {
providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err)
return user, "", err
}
return checkUserAndPubKey(user, pubKey)
}
func sqlCommonCheckAvailability(dbHandle *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return dbHandle.PingContext(ctx)
}
func sqlCommonGetUserByID(ID int64, dbHandle *sql.DB) (User, error) {
var user User
q := getUserByIDQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return user, err
}
defer stmt.Close()
row := stmt.QueryRow(ID)
return getUserFromDbRow(row, nil)
}
func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bool, dbHandle *sql.DB) error {
q := getUpdateQuotaQuery(reset)
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), username)
if err == nil {
providerLog(logger.LevelDebug, "quota updated for user %#v, files increment: %v size increment: %v is reset? %v",
username, filesAdd, sizeAdd, reset)
} else {
providerLog(logger.LevelWarn, "error updating quota for user %#v: %v", username, err)
}
return err
}
func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
q := getUpdateLastLoginQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.Exec(utils.GetTimeAsMsSinceEpoch(time.Now()), username)
if err == nil {
providerLog(logger.LevelDebug, "last login updated for user %#v", username)
} else {
providerLog(logger.LevelWarn, "error updating last login for user %#v: %v", username, err)
}
return err
}
func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error) {
q := getQuotaQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return 0, 0, err
}
defer stmt.Close()
var usedFiles int
var usedSize int64
err = stmt.QueryRow(username).Scan(&usedSize, &usedFiles)
if err != nil {
providerLog(logger.LevelWarn, "error getting quota for user: %v, error: %v", username, err)
return 0, 0, err
}
return usedFiles, usedSize, err
}
func sqlCommonCheckUserExists(username string, dbHandle *sql.DB) (User, error) {
var user User
q := getUserByUsernameQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return user, err
}
defer stmt.Close()
row := stmt.QueryRow(username)
return getUserFromDbRow(row, nil)
}
func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
err := validateUser(&user)
if err != nil {
return err
}
q := getAddUserQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
permissions, err := user.GetPermissionsAsJSON()
if err != nil {
return err
}
publicKeys, err := user.GetPublicKeysAsJSON()
if err != nil {
return err
}
filters, err := user.GetFiltersAsJSON()
if err != nil {
return err
}
fsConfig, err := user.GetFsConfigAsJSON()
if err != nil {
return err
}
virtualFolders, err := user.GetVirtualFoldersAsJSON()
if err != nil {
return err
}
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters),
string(fsConfig), string(virtualFolders))
return err
}
func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
err := validateUser(&user)
if err != nil {
return err
}
q := getUpdateUserQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
permissions, err := user.GetPermissionsAsJSON()
if err != nil {
return err
}
publicKeys, err := user.GetPublicKeysAsJSON()
if err != nil {
return err
}
filters, err := user.GetFiltersAsJSON()
if err != nil {
return err
}
fsConfig, err := user.GetFsConfigAsJSON()
if err != nil {
return err
}
virtualFolders, err := user.GetVirtualFoldersAsJSON()
if err != nil {
return err
}
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
string(filters), string(fsConfig), string(virtualFolders), user.ID)
return err
}
func sqlCommonDeleteUser(user User, dbHandle *sql.DB) error {
q := getDeleteUserQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.Exec(user.ID)
return err
}
func sqlCommonDumpUsers(dbHandle *sql.DB) ([]User, error) {
users := []User{}
q := getDumpUsersQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return nil, err
}
defer stmt.Close()
rows, err := stmt.Query()
if err == nil {
defer rows.Close()
for rows.Next() {
u, err := getUserFromDbRow(nil, rows)
if err != nil {
return users, err
}
err = addCredentialsToUser(&u)
if err != nil {
return users, err
}
users = append(users, u)
}
}
return users, err
}
func sqlCommonGetUsers(limit int, offset int, order string, username string, dbHandle *sql.DB) ([]User, error) {
users := []User{}
q := getUsersQuery(order, username)
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return nil, err
}
defer stmt.Close()
var rows *sql.Rows
if len(username) > 0 {
rows, err = stmt.Query(username, limit, offset)
} else {
rows, err = stmt.Query(limit, offset)
}
if err == nil {
defer rows.Close()
for rows.Next() {
u, err := getUserFromDbRow(nil, rows)
if err == nil {
users = append(users, HideUserSensitiveData(&u))
} else {
break
}
}
}
return users, err
}
func updateUserPermissionsFromDb(user *User, permissions string) error {
var err error
perms := make(map[string][]string)
err = json.Unmarshal([]byte(permissions), &perms)
if err == nil {
user.Permissions = perms
} else {
// compatibility layer: until version 0.9.4 permissions were a string list
var list []string
err = json.Unmarshal([]byte(permissions), &list)
if err != nil {
return err
}
perms["/"] = list
user.Permissions = perms
}
return err
}
func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
var user User
var permissions sql.NullString
var password sql.NullString
var publicKey sql.NullString
var filters sql.NullString
var fsConfig sql.NullString
var virtualFolders sql.NullString
var err error
if row != nil {
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
&virtualFolders)
} else {
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
&virtualFolders)
}
if err != nil {
if err == sql.ErrNoRows {
return user, &RecordNotFoundError{err: err.Error()}
}
return user, err
}
if password.Valid {
user.Password = password.String
}
// we can have a empty string or an invalid json in null string
// so we do a relaxed test if the field is optional, for example we
// populate public keys only if unmarshal does not return an error
if publicKey.Valid {
var list []string
err = json.Unmarshal([]byte(publicKey.String), &list)
if err == nil {
user.PublicKeys = list
}
}
if permissions.Valid {
err = updateUserPermissionsFromDb(&user, permissions.String)
if err != nil {
return user, err
}
}
if filters.Valid {
var userFilters UserFilters
err = json.Unmarshal([]byte(filters.String), &userFilters)
if err == nil {
user.Filters = userFilters
}
}
if fsConfig.Valid {
var fs Filesystem
err = json.Unmarshal([]byte(fsConfig.String), &fs)
if err == nil {
user.FsConfig = fs
}
}
if virtualFolders.Valid {
var list []vfs.VirtualFolder
err = json.Unmarshal([]byte(virtualFolders.String), &list)
if err == nil {
user.VirtualFolders = list
}
}
return user, err
}
func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) {
var result schemaVersion
q := getDatabaseVersionQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return result, err
}
defer stmt.Close()
row := stmt.QueryRow()
err = row.Scan(&result.Version)
return result, err
}
func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error {
q := getUpdateDBVersionQuery()
stmt, err := dbHandle.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.Exec(version)
return err
}
func sqlCommonUpdateDatabaseVersionWithTX(tx *sql.Tx, version int) error {
q := getUpdateDBVersionQuery()
stmt, err := tx.Prepare(q)
if err != nil {
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
return err
}
defer stmt.Close()
_, err = stmt.Exec(version)
return err
}

149
dataprovider/sqlite.go Normal file
View file

@ -0,0 +1,149 @@
package dataprovider
import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
)
const (
sqliteUsersTableSQL = `CREATE TABLE "{{users}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255)
NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL,
"gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL,
"permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL,
"last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL,
"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
"filesystem" text NULL);`
sqliteSchemaTableSQL = `CREATE TABLE "schema_version" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL);`
sqliteUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;`
)
// SQLiteProvider auth provider for SQLite database
type SQLiteProvider struct {
dbHandle *sql.DB
}
func initializeSQLiteProvider(basePath string) error {
var err error
var connectionString string
logSender = SQLiteDataProviderName
if len(config.ConnectionString) == 0 {
dbPath := config.Name
if !utils.IsFileInputValid(dbPath) {
return fmt.Errorf("Invalid database path: %#v", dbPath)
}
if !filepath.IsAbs(dbPath) {
dbPath = filepath.Join(basePath, dbPath)
}
connectionString = fmt.Sprintf("file:%v?cache=shared", dbPath)
} else {
connectionString = config.ConnectionString
}
dbHandle, err := sql.Open("sqlite3", connectionString)
if err == nil {
providerLog(logger.LevelDebug, "sqlite database handle created, connection string: %#v", connectionString)
dbHandle.SetMaxOpenConns(1)
provider = SQLiteProvider{dbHandle: dbHandle}
} else {
providerLog(logger.LevelWarn, "error creating sqlite database handler, connection string: %#v, error: %v",
connectionString, err)
}
return err
}
func (p SQLiteProvider) checkAvailability() error {
return sqlCommonCheckAvailability(p.dbHandle)
}
func (p SQLiteProvider) validateUserAndPass(username string, password string) (User, error) {
return sqlCommonValidateUserAndPass(username, password, p.dbHandle)
}
func (p SQLiteProvider) validateUserAndPubKey(username string, publicKey string) (User, string, error) {
return sqlCommonValidateUserAndPubKey(username, publicKey, p.dbHandle)
}
func (p SQLiteProvider) getUserByID(ID int64) (User, error) {
return sqlCommonGetUserByID(ID, p.dbHandle)
}
func (p SQLiteProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
}
func (p SQLiteProvider) updateLastLogin(username string) error {
return sqlCommonUpdateLastLogin(username, p.dbHandle)
}
func (p SQLiteProvider) getUsedQuota(username string) (int, int64, error) {
return sqlCommonGetUsedQuota(username, p.dbHandle)
}
func (p SQLiteProvider) userExists(username string) (User, error) {
return sqlCommonCheckUserExists(username, p.dbHandle)
}
func (p SQLiteProvider) addUser(user User) error {
return sqlCommonAddUser(user, p.dbHandle)
}
func (p SQLiteProvider) updateUser(user User) error {
return sqlCommonUpdateUser(user, p.dbHandle)
}
func (p SQLiteProvider) deleteUser(user User) error {
return sqlCommonDeleteUser(user, p.dbHandle)
}
func (p SQLiteProvider) dumpUsers() ([]User, error) {
return sqlCommonDumpUsers(p.dbHandle)
}
func (p SQLiteProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) {
return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle)
}
func (p SQLiteProvider) close() error {
return p.dbHandle.Close()
}
func (p SQLiteProvider) reloadConfig() error {
return nil
}
// initializeDatabase creates the initial database structure
func (p SQLiteProvider) initializeDatabase() error {
sqlUsers := strings.Replace(sqliteUsersTableSQL, "{{users}}", config.UsersTable, 1)
sql := sqlUsers + " " + sqliteSchemaTableSQL + " " + initialDBVersionSQL
_, err := p.dbHandle.Exec(sql)
return err
}
func (p SQLiteProvider) migrateDatabase() error {
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
if err != nil {
return err
}
if dbVersion.Version == sqlDatabaseVersion {
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
return nil
}
if dbVersion.Version == 1 {
return updateSQLiteDatabaseFrom1To2(p.dbHandle)
}
return nil
}
func updateSQLiteDatabaseFrom1To2(dbHandle *sql.DB) error {
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
sql := strings.Replace(sqliteUsersV2SQL, "{{users}}", config.UsersTable, 1)
_, err := dbHandle.Exec(sql)
if err != nil {
return err
}
return sqlCommonUpdateDatabaseVersion(dbHandle, 2)
}

View file

@ -0,0 +1,91 @@
package dataprovider
import "fmt"
const (
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," +
"virtual_folders"
)
func getSQLPlaceholders() []string {
var placeholders []string
for i := 1; i <= 20; i++ {
if config.Driver == PGSQLDataProviderName {
placeholders = append(placeholders, fmt.Sprintf("$%v", i))
} else {
placeholders = append(placeholders, "?")
}
}
return placeholders
}
func getUserByUsernameQuery() string {
return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0])
}
func getUserByIDQuery() string {
return fmt.Sprintf(`SELECT %v FROM %v WHERE id = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0])
}
func getUsersQuery(order string, username string) string {
if len(username) > 0 {
return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v ORDER BY username %v LIMIT %v OFFSET %v`,
selectUserFields, config.UsersTable, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2])
}
return fmt.Sprintf(`SELECT %v FROM %v ORDER BY username %v LIMIT %v OFFSET %v`, selectUserFields, config.UsersTable,
order, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getDumpUsersQuery() string {
return fmt.Sprintf(`SELECT %v FROM %v`, selectUserFields, config.UsersTable)
}
func getUpdateQuotaQuery(reset bool) string {
if reset {
return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v
WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v
WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
}
func getUpdateLastLoginQuery() string {
return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1])
}
func getQuotaQuery() string {
return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, config.UsersTable,
sqlPlaceholders[0])
}
func getAddUserQuery() string {
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
filesystem,virtual_folders)
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16])
}
func getUpdateUserQuery() string {
return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v,
virtual_folders=%v WHERE id = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15],
sqlPlaceholders[16])
}
func getDeleteUserQuery() string {
return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, config.UsersTable, sqlPlaceholders[0])
}
func getDatabaseVersionQuery() string {
return "SELECT version from schema_version LIMIT 1"
}
func getUpdateDBVersionQuery() string {
return fmt.Sprintf(`UPDATE schema_version SET version=%v`, sqlPlaceholders[0])
}

607
dataprovider/user.go Normal file
View file

@ -0,0 +1,607 @@
package dataprovider
import (
"encoding/json"
"fmt"
"net"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
)
// Available permissions for SFTP users
const (
// All permissions are granted
PermAny = "*"
// List items such as files and directories is allowed
PermListItems = "list"
// download files is allowed
PermDownload = "download"
// upload files is allowed
PermUpload = "upload"
// overwrite an existing file, while uploading, is allowed
// upload permission is required to allow file overwrite
PermOverwrite = "overwrite"
// delete files or directories is allowed
PermDelete = "delete"
// rename files or directories is allowed
PermRename = "rename"
// create directories is allowed
PermCreateDirs = "create_dirs"
// create symbolic links is allowed
PermCreateSymlinks = "create_symlinks"
// changing file or directory permissions is allowed
PermChmod = "chmod"
// changing file or directory owner and group is allowed
PermChown = "chown"
// changing file or directory access and modification time is allowed
PermChtimes = "chtimes"
)
// Available SSH login methods
const (
SSHLoginMethodPublicKey = "publickey"
SSHLoginMethodPassword = "password"
SSHLoginMethodKeyboardInteractive = "keyboard-interactive"
)
// ExtensionsFilter defines filters based on file extensions.
// These restrictions do not apply to files listing for performance reasons, so
// a denied file cannot be downloaded/overwritten/renamed but will still be
// it will still be listed in the list of files.
// System commands such as Git and rsync interacts with the filesystem directly
// and they are not aware about these restrictions so rsync is not allowed if
// extensions filters are defined and Git is not allowed inside a path with
// extensions filters
type ExtensionsFilter struct {
// SFTP/SCP path, if no other specific filter is defined, the filter apply for
// sub directories too.
// For example if filters are defined for the paths "/" and "/sub" then the
// filters for "/" are applied for any file outside the "/sub" directory
Path string `json:"path"`
// only files with these, case insensitive, extensions are allowed.
// Shell like expansion is not supported so you have to specify ".jpg" and
// not "*.jpg"
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
// files with these, case insensitive, extensions are not allowed.
// Denied file extensions are evaluated before the allowed ones
DeniedExtensions []string `json:"denied_extensions,omitempty"`
}
// UserFilters defines additional restrictions for a user
type UserFilters struct {
// only clients connecting from these IP/Mask are allowed.
// IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291
// for example "192.0.2.0/24" or "2001:db8::/32"
AllowedIP []string `json:"allowed_ip,omitempty"`
// clients connecting from these IP/Mask are not allowed.
// Denied rules will be evaluated before allowed ones
DeniedIP []string `json:"denied_ip,omitempty"`
// these login methods are not allowed.
// If null or empty any available login method is allowed
DeniedLoginMethods []string `json:"denied_login_methods,omitempty"`
// filters based on file extensions.
// Please note that these restrictions can be easily bypassed.
FileExtensions []ExtensionsFilter `json:"file_extensions,omitempty"`
}
// Filesystem defines cloud storage filesystem details
type Filesystem struct {
// 0 local filesystem, 1 Amazon S3 compatible, 2 Google Cloud Storage
Provider int `json:"provider"`
S3Config vfs.S3FsConfig `json:"s3config,omitempty"`
GCSConfig vfs.GCSFsConfig `json:"gcsconfig,omitempty"`
}
// User defines an SFTP user
type User struct {
// Database unique identifier
ID int64 `json:"id"`
// 1 enabled, 0 disabled (login is not allowed)
Status int `json:"status"`
// Username
Username string `json:"username"`
// Account expiration date as unix timestamp in milliseconds. An expired account cannot login.
// 0 means no expiration
ExpirationDate int64 `json:"expiration_date"`
// Password used for password authentication.
// For users created using SFTPGo REST API the password is be stored using argon2id hashing algo.
// Checking passwords stored with bcrypt, pbkdf2, md5crypt and sha512crypt is supported too.
Password string `json:"password,omitempty"`
// PublicKeys used for public key authentication. At least one between password and a public key is mandatory
PublicKeys []string `json:"public_keys,omitempty"`
// The user cannot upload or download files outside this directory. Must be an absolute path
HomeDir string `json:"home_dir"`
// Mapping between virtual paths and filesystem paths outside the home directory. Supported for local filesystem only
VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
// If sftpgo runs as root system user then the created files and directories will be assigned to this system UID
UID int `json:"uid"`
// If sftpgo runs as root system user then the created files and directories will be assigned to this system GID
GID int `json:"gid"`
// Maximum concurrent sessions. 0 means unlimited
MaxSessions int `json:"max_sessions"`
// Maximum size allowed as bytes. 0 means unlimited
QuotaSize int64 `json:"quota_size"`
// Maximum number of files allowed. 0 means unlimited
QuotaFiles int `json:"quota_files"`
// List of the granted permissions
Permissions map[string][]string `json:"permissions"`
// Used quota as bytes
UsedQuotaSize int64 `json:"used_quota_size"`
// Used quota as number of files
UsedQuotaFiles int `json:"used_quota_files"`
// Last quota update as unix timestamp in milliseconds
LastQuotaUpdate int64 `json:"last_quota_update"`
// Maximum upload bandwidth as KB/s, 0 means unlimited
UploadBandwidth int64 `json:"upload_bandwidth"`
// Maximum download bandwidth as KB/s, 0 means unlimited
DownloadBandwidth int64 `json:"download_bandwidth"`
// Last login as unix timestamp in milliseconds
LastLogin int64 `json:"last_login"`
// Additional restrictions
Filters UserFilters `json:"filters"`
// Filesystem configuration details
FsConfig Filesystem `json:"filesystem"`
}
// GetFilesystem returns the filesystem for this user
func (u *User) GetFilesystem(connectionID string) (vfs.Fs, error) {
if u.FsConfig.Provider == 1 {
return vfs.NewS3Fs(connectionID, u.GetHomeDir(), u.FsConfig.S3Config)
} else if u.FsConfig.Provider == 2 {
config := u.FsConfig.GCSConfig
config.CredentialFile = u.getGCSCredentialsFilePath()
return vfs.NewGCSFs(connectionID, u.GetHomeDir(), config)
}
return vfs.NewOsFs(connectionID, u.GetHomeDir(), u.VirtualFolders), nil
}
// GetPermissionsForPath returns the permissions for the given path.
// The path must be an SFTP path
func (u *User) GetPermissionsForPath(p string) []string {
permissions := []string{}
if perms, ok := u.Permissions["/"]; ok {
// if only root permissions are defined returns them unconditionally
if len(u.Permissions) == 1 {
return perms
}
// fallback permissions
permissions = perms
}
dirsForPath := utils.GetDirsForSFTPPath(p)
// dirsForPath contains all the dirs for a given path in reverse order
// for example if the path is: /1/2/3/4 it contains:
// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ]
// so the first match is the one we are interested to
for _, val := range dirsForPath {
if perms, ok := u.Permissions[val]; ok {
permissions = perms
break
}
}
return permissions
}
// AddVirtualDirs adds virtual folders, if defined, to the given files list
func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo {
if len(u.VirtualFolders) == 0 {
return list
}
for _, v := range u.VirtualFolders {
if path.Dir(v.VirtualPath) == sftpPath {
fi := vfs.NewFileInfo(path.Base(v.VirtualPath), true, 0, time.Time{})
found := false
for index, f := range list {
if f.Name() == fi.Name() {
list[index] = fi
found = true
break
}
}
if !found {
list = append(list, fi)
}
}
}
return list
}
// IsVirtualFolder returns true if the specified sftp path is a virtual folder
func (u *User) IsVirtualFolder(sftpPath string) bool {
for _, v := range u.VirtualFolders {
if sftpPath == v.VirtualPath {
return true
}
}
return false
}
// HasPerm returns true if the user has the given permission or any permission
func (u *User) HasPerm(permission, path string) bool {
perms := u.GetPermissionsForPath(path)
if utils.IsStringInSlice(PermAny, perms) {
return true
}
return utils.IsStringInSlice(permission, perms)
}
// HasPerms return true if the user has all the given permissions
func (u *User) HasPerms(permissions []string, path string) bool {
perms := u.GetPermissionsForPath(path)
if utils.IsStringInSlice(PermAny, perms) {
return true
}
for _, permission := range permissions {
if !utils.IsStringInSlice(permission, perms) {
return false
}
}
return true
}
// IsLoginMethodAllowed returns true if the specified login method is allowed for the user
func (u *User) IsLoginMethodAllowed(loginMetod string) bool {
if len(u.Filters.DeniedLoginMethods) == 0 {
return true
}
if utils.IsStringInSlice(loginMetod, u.Filters.DeniedLoginMethods) {
return false
}
return true
}
// IsFileAllowed returns true if the specified file is allowed by the file restrictions filters
func (u *User) IsFileAllowed(sftpPath string) bool {
if len(u.Filters.FileExtensions) == 0 {
return true
}
dirsForPath := utils.GetDirsForSFTPPath(path.Dir(sftpPath))
var filter ExtensionsFilter
for _, dir := range dirsForPath {
for _, f := range u.Filters.FileExtensions {
if f.Path == dir {
filter = f
break
}
}
if len(filter.Path) > 0 {
break
}
}
if len(filter.Path) > 0 {
toMatch := strings.ToLower(sftpPath)
for _, denied := range filter.DeniedExtensions {
if strings.HasSuffix(toMatch, denied) {
return false
}
}
for _, allowed := range filter.AllowedExtensions {
if strings.HasSuffix(toMatch, allowed) {
return true
}
}
return len(filter.AllowedExtensions) == 0
}
return true
}
// IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr.
// If AllowedIP is defined only the specified IP/Mask can login.
// If DeniedIP is defined the specified IP/Mask cannot login.
// If an IP is both allowed and denied then login will be denied
func (u *User) IsLoginFromAddrAllowed(remoteAddr string) bool {
if len(u.Filters.AllowedIP) == 0 && len(u.Filters.DeniedIP) == 0 {
return true
}
remoteIP := net.ParseIP(utils.GetIPFromRemoteAddress(remoteAddr))
// if remoteIP is invalid we allow login, this should never happen
if remoteIP == nil {
logger.Warn(logSender, "", "login allowed for invalid IP. remote address: %#v", remoteAddr)
return true
}
for _, IPMask := range u.Filters.DeniedIP {
_, IPNet, err := net.ParseCIDR(IPMask)
if err != nil {
return false
}
if IPNet.Contains(remoteIP) {
return false
}
}
for _, IPMask := range u.Filters.AllowedIP {
_, IPNet, err := net.ParseCIDR(IPMask)
if err != nil {
return false
}
if IPNet.Contains(remoteIP) {
return true
}
}
return len(u.Filters.AllowedIP) == 0
}
// GetPermissionsAsJSON returns the permissions as json byte array
func (u *User) GetPermissionsAsJSON() ([]byte, error) {
return json.Marshal(u.Permissions)
}
// GetPublicKeysAsJSON returns the public keys as json byte array
func (u *User) GetPublicKeysAsJSON() ([]byte, error) {
return json.Marshal(u.PublicKeys)
}
// GetFiltersAsJSON returns the filters as json byte array
func (u *User) GetFiltersAsJSON() ([]byte, error) {
return json.Marshal(u.Filters)
}
// GetFsConfigAsJSON returns the filesystem config as json byte array
func (u *User) GetFsConfigAsJSON() ([]byte, error) {
return json.Marshal(u.FsConfig)
}
// GetVirtualFoldersAsJSON returns the virtual folders as json byte array
func (u *User) GetVirtualFoldersAsJSON() ([]byte, error) {
return json.Marshal(u.VirtualFolders)
}
// GetUID returns a validate uid, suitable for use with os.Chown
func (u *User) GetUID() int {
if u.UID <= 0 || u.UID > 65535 {
return -1
}
return u.UID
}
// GetGID returns a validate gid, suitable for use with os.Chown
func (u *User) GetGID() int {
if u.GID <= 0 || u.GID > 65535 {
return -1
}
return u.GID
}
// GetHomeDir returns the shortest path name equivalent to the user's home directory
func (u *User) GetHomeDir() string {
return filepath.Clean(u.HomeDir)
}
// HasQuotaRestrictions returns true if there is a quota restriction on number of files or size or both
func (u *User) HasQuotaRestrictions() bool {
return u.QuotaFiles > 0 || u.QuotaSize > 0
}
// GetQuotaSummary returns used quota and limits if defined
func (u *User) GetQuotaSummary() string {
var result string
result = "Files: " + strconv.Itoa(u.UsedQuotaFiles)
if u.QuotaFiles > 0 {
result += "/" + strconv.Itoa(u.QuotaFiles)
}
if u.UsedQuotaSize > 0 || u.QuotaSize > 0 {
result += ". Size: " + utils.ByteCountSI(u.UsedQuotaSize)
if u.QuotaSize > 0 {
result += "/" + utils.ByteCountSI(u.QuotaSize)
}
}
return result
}
// GetPermissionsAsString returns the user's permissions as comma separated string
func (u *User) GetPermissionsAsString() string {
result := ""
for dir, perms := range u.Permissions {
var dirPerms string
for _, p := range perms {
if len(dirPerms) > 0 {
dirPerms += ", "
}
dirPerms += p
}
dp := fmt.Sprintf("%#v: %#v", dir, dirPerms)
if dir == "/" {
if len(result) > 0 {
result = dp + ", " + result
} else {
result = dp
}
} else {
if len(result) > 0 {
result += ", "
}
result += dp
}
}
return result
}
// GetBandwidthAsString returns bandwidth limits if defines
func (u *User) GetBandwidthAsString() string {
result := "Download: "
if u.DownloadBandwidth > 0 {
result += utils.ByteCountSI(u.DownloadBandwidth*1000) + "/s."
} else {
result += "unlimited."
}
result += " Upload: "
if u.UploadBandwidth > 0 {
result += utils.ByteCountSI(u.UploadBandwidth*1000) + "/s."
} else {
result += "unlimited."
}
return result
}
// GetInfoString returns user's info as string.
// Storage provider, number of public keys, max sessions, uid,
// gid, denied and allowed IP/Mask are returned
func (u *User) GetInfoString() string {
var result string
if u.LastLogin > 0 {
t := utils.GetTimeFromMsecSinceEpoch(u.LastLogin)
result += fmt.Sprintf("Last login: %v ", t.Format("2006-01-02 15:04:05")) // YYYY-MM-DD HH:MM:SS
}
if u.FsConfig.Provider == 1 {
result += fmt.Sprintf("Storage: S3 ")
} else if u.FsConfig.Provider == 2 {
result += fmt.Sprintf("Storage: GCS ")
}
if len(u.PublicKeys) > 0 {
result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys))
}
if u.MaxSessions > 0 {
result += fmt.Sprintf("Max sessions: %v ", u.MaxSessions)
}
if u.UID > 0 {
result += fmt.Sprintf("UID: %v ", u.UID)
}
if u.GID > 0 {
result += fmt.Sprintf("GID: %v ", u.GID)
}
if len(u.Filters.DeniedIP) > 0 {
result += fmt.Sprintf("Denied IP/Mask: %v ", len(u.Filters.DeniedIP))
}
if len(u.Filters.AllowedIP) > 0 {
result += fmt.Sprintf("Allowed IP/Mask: %v ", len(u.Filters.AllowedIP))
}
return result
}
// GetExpirationDateAsString returns expiration date formatted as YYYY-MM-DD
func (u *User) GetExpirationDateAsString() string {
if u.ExpirationDate > 0 {
t := utils.GetTimeFromMsecSinceEpoch(u.ExpirationDate)
return t.Format("2006-01-02")
}
return ""
}
// GetAllowedIPAsString returns the allowed IP as comma separated string
func (u User) GetAllowedIPAsString() string {
result := ""
for _, IPMask := range u.Filters.AllowedIP {
if len(result) > 0 {
result += ","
}
result += IPMask
}
return result
}
// GetDeniedIPAsString returns the denied IP as comma separated string
func (u User) GetDeniedIPAsString() string {
result := ""
for _, IPMask := range u.Filters.DeniedIP {
if len(result) > 0 {
result += ","
}
result += IPMask
}
return result
}
func (u *User) getACopy() User {
pubKeys := make([]string, len(u.PublicKeys))
copy(pubKeys, u.PublicKeys)
virtualFolders := make([]vfs.VirtualFolder, len(u.VirtualFolders))
copy(virtualFolders, u.VirtualFolders)
permissions := make(map[string][]string)
for k, v := range u.Permissions {
perms := make([]string, len(v))
copy(perms, v)
permissions[k] = perms
}
filters := UserFilters{}
filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
copy(filters.AllowedIP, u.Filters.AllowedIP)
filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
copy(filters.DeniedIP, u.Filters.DeniedIP)
filters.DeniedLoginMethods = make([]string, len(u.Filters.DeniedLoginMethods))
copy(filters.DeniedLoginMethods, u.Filters.DeniedLoginMethods)
filters.FileExtensions = make([]ExtensionsFilter, len(u.Filters.FileExtensions))
copy(filters.FileExtensions, u.Filters.FileExtensions)
fsConfig := Filesystem{
Provider: u.FsConfig.Provider,
S3Config: vfs.S3FsConfig{
Bucket: u.FsConfig.S3Config.Bucket,
Region: u.FsConfig.S3Config.Region,
AccessKey: u.FsConfig.S3Config.AccessKey,
AccessSecret: u.FsConfig.S3Config.AccessSecret,
Endpoint: u.FsConfig.S3Config.Endpoint,
StorageClass: u.FsConfig.S3Config.StorageClass,
KeyPrefix: u.FsConfig.S3Config.KeyPrefix,
},
GCSConfig: vfs.GCSFsConfig{
Bucket: u.FsConfig.GCSConfig.Bucket,
CredentialFile: u.FsConfig.GCSConfig.CredentialFile,
AutomaticCredentials: u.FsConfig.GCSConfig.AutomaticCredentials,
StorageClass: u.FsConfig.GCSConfig.StorageClass,
KeyPrefix: u.FsConfig.GCSConfig.KeyPrefix,
},
}
return User{
ID: u.ID,
Username: u.Username,
Password: u.Password,
PublicKeys: pubKeys,
HomeDir: u.HomeDir,
VirtualFolders: virtualFolders,
UID: u.UID,
GID: u.GID,
MaxSessions: u.MaxSessions,
QuotaSize: u.QuotaSize,
QuotaFiles: u.QuotaFiles,
Permissions: permissions,
UsedQuotaSize: u.UsedQuotaSize,
UsedQuotaFiles: u.UsedQuotaFiles,
LastQuotaUpdate: u.LastQuotaUpdate,
UploadBandwidth: u.UploadBandwidth,
DownloadBandwidth: u.DownloadBandwidth,
Status: u.Status,
ExpirationDate: u.ExpirationDate,
LastLogin: u.LastLogin,
Filters: filters,
FsConfig: fsConfig,
}
}
func (u *User) getNotificationFieldsAsSlice(action string) []string {
return []string{action, u.Username,
strconv.FormatInt(u.ID, 10),
strconv.FormatInt(int64(u.Status), 10),
strconv.FormatInt(int64(u.ExpirationDate), 10),
u.HomeDir,
strconv.FormatInt(int64(u.UID), 10),
strconv.FormatInt(int64(u.GID), 10),
}
}
func (u *User) getNotificationFieldsAsEnvVars(action string) []string {
return []string{fmt.Sprintf("SFTPGO_USER_ACTION=%v", action),
fmt.Sprintf("SFTPGO_USER_USERNAME=%v", u.Username),
fmt.Sprintf("SFTPGO_USER_PASSWORD=%v", u.Password),
fmt.Sprintf("SFTPGO_USER_ID=%v", u.ID),
fmt.Sprintf("SFTPGO_USER_STATUS=%v", u.Status),
fmt.Sprintf("SFTPGO_USER_EXPIRATION_DATE=%v", u.ExpirationDate),
fmt.Sprintf("SFTPGO_USER_HOME_DIR=%v", u.HomeDir),
fmt.Sprintf("SFTPGO_USER_UID=%v", u.UID),
fmt.Sprintf("SFTPGO_USER_GID=%v", u.GID),
fmt.Sprintf("SFTPGO_USER_QUOTA_FILES=%v", u.QuotaFiles),
fmt.Sprintf("SFTPGO_USER_QUOTA_SIZE=%v", u.QuotaSize),
fmt.Sprintf("SFTPGO_USER_UPLOAD_BANDWIDTH=%v", u.UploadBandwidth),
fmt.Sprintf("SFTPGO_USER_DOWNLOAD_BANDWIDTH=%v", u.DownloadBandwidth),
fmt.Sprintf("SFTPGO_USER_MAX_SESSIONS=%v", u.MaxSessions),
fmt.Sprintf("SFTPGO_USER_FS_PROVIDER=%v", u.FsConfig.Provider)}
}
func (u *User) getGCSCredentialsFilePath() string {
return filepath.Join(credentialsDirPath, fmt.Sprintf("%v_gcs_credentials.json", u.Username))
}

5
docker/README.md Normal file
View file

@ -0,0 +1,5 @@
## Dockerfile examples
Sample Dockerfiles for `sftpgo` daemon and the REST API CLI.
We don't want to add a `Dockerfile` for each single `sftpgo` configuration options or data provider. You can use the docker configurations here as starting point that you can customize to run `sftpgo` with [Docker](http://www.docker.io "Docker").

View file

@ -0,0 +1,8 @@
FROM debian:latest
LABEL maintainer="nicola.murino@gmail.com"
RUN apt-get update && apt-get install -y curl python3-requests python3-pygments
RUN curl https://raw.githubusercontent.com/drakkan/sftpgo/master/scripts/sftpgo_api_cli.py --output /usr/bin/sftpgo_api_cli.py
ENTRYPOINT ["python3", "/usr/bin/sftpgo_api_cli.py" ]
CMD []

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH=$(uname -m)
case ${ARCH} in
x86_64)
SUFFIX=amd64
;;
aarch64)
SUFFIX=arm64
;;
*)
SUFFIX=ppc64le
;;
esac
echo "Downloading plugins for arch ${SUFFIX}"
PLUGINS=(geoipfilter kms pubsub eventstore eventsearch auth)
for PLUGIN in "${PLUGINS[@]}"; do
URL="https://github.com/sftpgo/sftpgo-plugin-${PLUGIN}/releases/latest/download/sftpgo-plugin-${PLUGIN}-linux-${SUFFIX}"
DEST="/usr/local/bin/sftpgo-plugin-${PLUGIN}"
echo "Downloading ${PLUGIN}..."
if curl --fail --silent --show-error -L "${URL}" --output "${DEST}"; then
chmod 755 "${DEST}"
else
echo "Error: Failed to download ${PLUGIN}" >&2
exit 1
fi
done
echo "All plugins downloaded successfully"

View file

@ -0,0 +1,31 @@
FROM golang:alpine as builder
RUN apk add --no-cache git gcc g++ ca-certificates \
&& go get -d github.com/drakkan/sftpgo
WORKDIR /go/src/github.com/drakkan/sftpgo
# uncomment the next line to get the latest stable version instead of the latest git
#RUN git checkout `git rev-list --tags --max-count=1`
RUN go build -i -ldflags "-s -w -X github.com/drakkan/sftpgo/utils.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/utils.date=`date -u +%FT%TZ`" -o /go/bin/sftpgo
FROM alpine:latest
RUN apk add --no-cache ca-certificates su-exec \
&& mkdir -p /data /etc/sftpgo /srv/sftpgo/config /srv/sftpgo/web /srv/sftpgo/backups
# ca-certificates is needed for Cloud Storage Support and to expose the REST API over HTTPS.
# If you install git then ca-certificates will be automatically installed as dependency.
# git, rsync and ca-certificates are optional, uncomment the next line to add support for them if needed.
#RUN apk add --no-cache git rsync ca-certificates
COPY --from=builder /go/bin/sftpgo /bin/
COPY --from=builder /go/src/github.com/drakkan/sftpgo/sftpgo.json /etc/sftpgo/sftpgo.json
COPY --from=builder /go/src/github.com/drakkan/sftpgo/templates /srv/sftpgo/web/templates
COPY --from=builder /go/src/github.com/drakkan/sftpgo/static /srv/sftpgo/web/static
COPY docker-entrypoint.sh /bin/entrypoint.sh
RUN chmod +x /bin/entrypoint.sh
VOLUME [ "/data", "/srv/sftpgo/config", "/srv/sftpgo/backups" ]
EXPOSE 2022 8080
ENTRYPOINT ["/bin/entrypoint.sh"]
CMD ["serve"]

View file

@ -0,0 +1,46 @@
# SFTPGo with Docker and Alpine
This DockerFile is made to build image to host multiple instances of SFTPGo started with different users.
### Example
> 1003 is a custom uid:gid for this instance of SFTPGo
```bash
# Prereq on docker host
sudo groupadd -g 1003 sftpgrp && \
sudo useradd -u 1003 -g 1003 sftpuser -d /home/sftpuser/ && \
sudo -u sftpuser mkdir /home/sftpuser/{conf,data} && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20190828.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191112.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191230.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20200116.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sftpgo.json -o /home/sftpuser/conf/sftpgo.json
# Get and build SFTPGo image
git clone https://github.com/drakkan/sftpgo.git && \
cd sftpgo && \
sudo docker build -t sftpgo docker/sftpgo/alpine/
# Starting image
sudo docker run --name sftpgo \
-e SFTPGO_LOG_FILE_PATH= \
-e SFTPGO_CONFIG_DIR=/srv/sftpgo/config \
-e SFTPGO_HTTPD__TEMPLATES_PATH=/srv/sftpgo/web/templates \
-e SFTPGO_HTTPD__STATIC_FILES_PATH=/srv/sftpgo/web/static \
-e SFTPGO_HTTPD__BACKUPS_PATH=/srv/sftpgo/backups \
-p 8080:8080 \
-p 2022:2022 \
-e PUID=1003 \
-e GUID=1003 \
-v /home/sftpuser/conf/:/srv/sftpgo/config \
-v /home/sftpuser/data:/data \
-v /home/sftpuser/backups:/srv/sftpgo/backups \
sftpgo
```
The script `entrypoint.sh` makes sure to correct the permissions of directories and start the process with the right user
Several images can be run with different parameters.
### Custom systemd script
An example of systemd script is present [here](sftpgo.service), with `Environment` parameter to set `PUID` and `GUID`
`WorkingDirectory` parameter must be exist with one file in this directory like `sftpgo-${PUID}.env` corresponding to the variable file for SFTPGo instance.

View file

@ -0,0 +1,7 @@
#!/bin/sh
set -eu
chown -R "${PUID}:${GUID}" /data /etc/sftpgo /srv/sftpgo/config /srv/sftpgo/backups \
&& exec su-exec "${PUID}:${GUID}" \
/bin/sftpgo "$@"

View file

@ -0,0 +1,31 @@
[Unit]
Description=SFTPGo sftp server
After=docker.service
[Service]
User=root
Group=root
WorkingDirectory=/etc/sftpgo
Environment=PUID=1003
Environment=GUID=1003
EnvironmentFile=-/etc/sysconfig/sftpgo.conf
ExecStartPre=-docker kill sftpgo
ExecStartPre=-docker rm sftpgo
ExecStart=docker run --name sftpgo \
--env-file sftpgo-${PUID}.env \
-e PUID=${PUID} \
-e GUID=${GUID} \
-e SFTPGO_HTTPD__TEMPLATES_PATH=/srv/sftpgo/web/templates \
-e SFTPGO_HTTPD__STATIC_FILES_PATH=/srv/sftpgo/web/static \
-p 8080:8080 \
-p 2022:2022 \
-v /home/sftpuser/conf/:/srv/sftpgo/config \
-v /home/sftpuser/data:/data \
sftpgo
ExecStop=docker stop sftpgo
SyslogIdentifier=sftpgo
Restart=always
RestartSec=10s
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,72 @@
# we use a multi stage build to have a separate build and run env
FROM golang:latest as buildenv
LABEL maintainer="nicola.murino@gmail.com"
RUN go get -d github.com/drakkan/sftpgo
WORKDIR /go/src/github.com/drakkan/sftpgo
# uncomment the next line to get the latest stable version instead of the latest git
#RUN git checkout `git rev-list --tags --max-count=1`
RUN go build -i -ldflags "-s -w -X github.com/drakkan/sftpgo/utils.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/utils.date=`date -u +%FT%TZ`" -o sftpgo
# now define the run environment
FROM debian:latest
# ca-certificates is needed for Cloud Storage Support and to expose the REST API over HTTPS.
# If you install git then ca-certificates will be automatically installed as dependency.
# git, rsync and ca-certificates are optional, uncomment the next line to add support for them if needed.
#RUN apt-get update && apt-get install -y git rsync ca-certificates
ARG BASE_DIR=/app
ARG DATA_REL_DIR=data
ARG CONFIG_REL_DIR=config
ARG BACKUP_REL_DIR=backups
ARG USERNAME=sftpgo
ARG GROUPNAME=sftpgo
ARG UID=515
ARG GID=515
ARG WEB_REL_PATH=web
# HOME_DIR for sftpgo itself
ENV HOME_DIR=${BASE_DIR}/${USERNAME}
# DATA_DIR, this is a volume that you can use hold user's home dirs
ENV DATA_DIR=${BASE_DIR}/${DATA_REL_DIR}
# CONFIG_DIR, this is a volume to persist the daemon private keys, configuration file ecc..
ENV CONFIG_DIR=${BASE_DIR}/${CONFIG_REL_DIR}
# BACKUPS_DIR, this is a volume to store backups done using "dumpdata" REST API
ENV BACKUPS_DIR=${BASE_DIR}/${BACKUP_REL_DIR}
ENV WEB_DIR=${BASE_DIR}/${WEB_REL_PATH}
RUN mkdir -p ${DATA_DIR} ${CONFIG_DIR} ${WEB_DIR} ${BACKUPS_DIR}
RUN groupadd --system -g ${GID} ${GROUPNAME}
RUN useradd --system --create-home --no-log-init --home-dir ${HOME_DIR} --comment "SFTPGo user" --shell /bin/false --gid ${GID} --uid ${UID} ${USERNAME}
WORKDIR ${HOME_DIR}
RUN mkdir -p bin .config/sftpgo
ENV PATH ${HOME_DIR}/bin:$PATH
COPY --from=buildenv /go/src/github.com/drakkan/sftpgo/sftpgo bin/sftpgo
# default config file to use if no config file is found inside the CONFIG_DIR volume.
# You can override each configuration options via env vars too
COPY --from=buildenv /go/src/github.com/drakkan/sftpgo/sftpgo.json .config/sftpgo/
COPY --from=buildenv /go/src/github.com/drakkan/sftpgo/templates ${WEB_DIR}/templates
COPY --from=buildenv /go/src/github.com/drakkan/sftpgo/static ${WEB_DIR}/static
RUN chown -R ${UID}:${GID} ${DATA_DIR} ${BACKUPS_DIR}
# run as non root user
USER ${USERNAME}
EXPOSE 2022 8080
# the defined volumes must have write access for the UID and GID defined above
VOLUME [ "$DATA_DIR", "$CONFIG_DIR", "$BACKUPS_DIR" ]
# override some default configuration options using env vars
ENV SFTPGO_CONFIG_DIR=${CONFIG_DIR}
# setting SFTPGO_LOG_FILE_PATH to an empty string will log to stdout
ENV SFTPGO_LOG_FILE_PATH=""
ENV SFTPGO_HTTPD__BIND_ADDRESS=""
ENV SFTPGO_HTTPD__TEMPLATES_PATH=${WEB_DIR}/templates
ENV SFTPGO_HTTPD__STATIC_FILES_PATH=${WEB_DIR}/static
ENV SFTPGO_DATA_PROVIDER__USERS_BASE_DIR=${DATA_DIR}
ENV SFTPGO_HTTPD__BACKUPS_PATH=${BACKUPS_DIR}
ENTRYPOINT ["sftpgo"]
CMD ["serve"]

View file

@ -0,0 +1,22 @@
## Dockerfile based on Debian stable
Please read the comments inside the `Dockerfile` to learn how to customize things for your setup.
You can build the container image using `docker build`, for example:
```bash
docker build -t="drakkan/sftpgo" .
```
and you can run the Dockerfile using something like this:
```bash
docker run --name sftpgo -p 8080:8080 -p 2022:2022 --mount type=bind,source=/srv/sftpgo/data,target=/app/data --mount type=bind,source=/srv/sftpgo/config,target=/app/config --mount type=bind,source=/srv/sftpgo/backups,target=/app/backups drakkan/sftpgo
```
where `/srv/sftpgo/data`, `/srv/sftpgo/config` and `/srv/sftpgo/backups` are folders on the host system with write access for UID/GID defined inside the `Dockerfile`. You can choose to create a new user, on the host system, with a matching UID/GID pair or simply do something like:
```bash
chown -R <UID>:<GID> /srv/sftpgo/data /srv/sftpgo/config /srv/sftpgo/backups
```

61
docs/account.md Normal file
View file

@ -0,0 +1,61 @@
# Account's configuration properties
For each account, the following properties can be configured:
- `username`
- `password` used for password authentication. For users created using SFTPGo REST API, if the password has no known hashing algo prefix, it will be stored using argon2id. SFTPGo supports checking passwords stored with bcrypt, pbkdf2, md5crypt and sha512crypt too. For pbkdf2 the supported format is `$<algo>$<iterations>$<salt>$<hashed pwd base64 encoded>`, where algo is `pbkdf2-sha1` or `pbkdf2-sha256` or `pbkdf2-sha512`. For example the `pbkdf2-sha256` of the word `password` using 150000 iterations and `E86a9YMX3zC7` as salt must be stored as `$pbkdf2-sha256$150000$E86a9YMX3zC7$R5J62hsSq+pYw00hLLPKBbcGXmq7fj5+/M0IFoYtZbo=`. For bcrypt the format must be the one supported by golang's [crypto/bcrypt](https://godoc.org/golang.org/x/crypto/bcrypt) package, for example the password `secret` with cost `14` must be stored as `$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK`. For md5crypt and sha512crypt we support the format used in `/etc/shadow` with the `$1$` and `$6$` prefix, this is useful if you are migrating from Unix system user accounts. We support Apache md5crypt (`$apr1$` prefix) too. Using the REST API you can send a password hashed as bcrypt, pbkdf2, md5crypt or sha512crypt and it will be stored as is.
- `public_keys` array of public keys. At least one public key or the password is mandatory.
- `status` 1 means "active", 0 "inactive". An inactive account cannot login.
- `expiration_date` expiration date as unix timestamp in milliseconds. An expired account cannot login. 0 means no expiration.
- `home_dir` the user cannot upload or download files outside this directory. Must be an absolute path.
- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login
- `uid`, `gid`. If SFTPGo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows or if SFTPGo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs SFTPGo.
- `max_sessions` maximum concurrent sessions. 0 means unlimited.
- `quota_size` maximum size allowed as bytes. 0 means unlimited.
- `quota_files` maximum number of files allowed. 0 means unlimited.
- `permissions` the following per directory permissions are supported:
- `*` all permissions are granted
- `list` list items is allowed
- `download` download files is allowed
- `upload` upload files is allowed
- `overwrite` overwrite an existing file, while uploading, is allowed. `upload` permission is required to allow file overwrite
- `delete` delete files or directories is allowed
- `rename` rename files or directories is allowed
- `create_dirs` create directories is allowed
- `create_symlinks` create symbolic links is allowed
- `chmod` changing file or directory permissions is allowed. On Windows, only the 0200 bit (owner writable) of mode is used; it controls whether the file's read-only attribute is set or cleared. The other bits are currently unused. Use mode 0400 for a read-only file and 0600 for a readable+writable file.
- `chown` changing file or directory owner and group is allowed. Changing owner and group is not supported on Windows.
- `chtimes` changing file or directory access and modification time is allowed
- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited.
- `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited.
- `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
- `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied
- `denied_login_methods`, List of login methods not allowed. The following login methods are supported:
- `publickey`
- `password`
- `keyboard-interactive`
- `file_extensions`, list of struct. These restrictions do not apply to files listing for performance reasons, so a denied file cannot be downloaded/overwritten/renamed but it will still be listed in the list of files. Please note that these restrictions can be easily bypassed. Each struct contains the following fields:
- `allowed_extensions`, list of, case insensitive, allowed files extension. Shell like expansion is not supported so you have to specify `.jpg` and not `*.jpg`. Any file that does not end with this suffix will be denied
- `denied_extensions`, list of, case insensitive, denied files extension. Denied file extensions are evaluated before the allowed ones
- `path`, SFTP/SCP path, if no other specific filter is defined, the filter apply for sub directories too. For example if filters are defined for the paths `/` and `/sub` then the filters for `/` are applied for any file outside the `/sub` directory
- `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported
- `s3_bucket`, required for S3 filesystem
- `s3_region`, required for S3 filesystem. Must match the region for your bucket. You can find here the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example if your bucket is at `Frankfurt` you have to set the region to `eu-central-1`
- `s3_access_key`
- `s3_access_secret`, if provided it is stored encrypted (AES-256-GCM)
- `s3_endpoint`, specifies a S3 endpoint (server) different from AWS. It is not required if you are connecting to AWS
- `s3_storage_class`, leave blank to use the default or specify a valid AWS [storage class](https://docs.aws.amazon.com/AmazonS3/latest/dev/storage-class-intro.html)
- `s3_key_prefix`, allows to restrict access to the virtual folder identified by this prefix and its contents
- `gcs_bucket`, required for GCS filesystem
- `gcs_credentials`, Google Cloud Storage JSON credentials base64 encoded
- `gcs_automatic_credentials`, integer. Set to 1 to use Application Default Credentials strategy or set to 0 to use explicit credentials via `gcs_credentials`
- `gcs_storage_class`
- `gcs_key_prefix`, allows to restrict access to the virtual folder identified by this prefix and its contents
These properties are stored inside the data provider.
If you want to use your existing accounts, you have these options:
- If your accounts are aleady stored inside a supported database, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view
- you can import your users inside SFTPGo. Take a look at [sftpgo_api_cli.py](../scripts#convert-users-from-other-stores "SFTPGo API CLI script"), it can convert and import users from Linux system users and Pure-FTPd/ProFTPD virtual users
- you can use an external authentication program

34
docs/build-from-source.md Normal file
View file

@ -0,0 +1,34 @@
# Build SFTPGo from source
Install the package to your [\$GOPATH](https://github.com/golang/go/wiki/GOPATH "GOPATH") with the [go tool](https://golang.org/cmd/go/ "go command") from shell:
```bash
go get -u github.com/drakkan/sftpgo
```
Make sure [Git](https://git-scm.com/downloads) is installed on your machine and in your system's `PATH`.
SFTPGo depends on [go-sqlite3](https://github.com/mattn/go-sqlite3) which is a CGO package and so it requires a `C` compiler at build time.
On Linux and macOS, a compiler is easy to install or already installed. On Windows, you need to download [MinGW-w64](https://sourceforge.net/projects/mingw-w64/files/) and build SFTPGo from its command prompt.
The compiler is a build time only dependency. It is not required at runtime.
If you don't need SQLite, you can also get/build SFTPGo setting the environment variable `GCO_ENABLED` to 0. This way, SQLite support will be disabled and PostgreSQL, MySQL, bbolt and memory data providers will keep working. In this way, you don't need a `C` compiler for building.
Version info, such as git commit and build date, can be embedded setting the following string variables at build time:
- `github.com/drakkan/sftpgo/utils.commit`
- `github.com/drakkan/sftpgo/utils.date`
For example, you can build using the following command:
```bash
go build -i -ldflags "-s -w -X github.com/drakkan/sftpgo/utils.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/utils.date=`date -u +%FT%TZ`" -o sftpgo
```
You should get a version that includes git commit and build date like this one:
```bash
$ sftpgo -v
SFTPGo version: 0.9.0-dev-90607d4-dirty-2019-08-08T19:28:36Z
```

78
docs/custom-actions.md Normal file
View file

@ -0,0 +1,78 @@
# Custom Actions
The `actions` struct inside the "sftpd" configuration section allows to configure the actions for file operations and SSH commands.
Actions will not be executed if an error is detected, and so a partial file is uploaded or an SSH command is not successfully completed. The `upload` condition includes both uploads to new files and overwrite of existing files. The `ssh_cmd` condition will be triggered after a command is successfully executed via SSH. `scp` will trigger the `download` and `upload` conditions and not `ssh_cmd`.
The `command`, if defined, is invoked with the following arguments:
- `action`, string, possible values are: `download`, `upload`, `delete`, `rename`, `ssh_cmd`
- `username`
- `path` is the full filesystem path, can be empty for some ssh commands
- `target_path`, non empty for `rename` action
- `ssh_cmd`, non empty for `ssh_cmd` action
The `command` can also read the following environment variables:
- `SFTPGO_ACTION`
- `SFTPGO_ACTION_USERNAME`
- `SFTPGO_ACTION_PATH`
- `SFTPGO_ACTION_TARGET`, non empty for `rename` `SFTPGO_ACTION`
- `SFTPGO_ACTION_SSH_CMD`, non empty for `ssh_cmd` `SFTPGO_ACTION`
- `SFTPGO_ACTION_FILE_SIZE`, non empty for `upload`, `download` and `delete` `SFTPGO_ACTION`
- `SFTPGO_ACTION_LOCAL_FILE`, `true` if the affected file is stored on the local filesystem, otherwise `false`
Previous global environment variables aren't cleared when the script is called.
The `command` must finish within 30 seconds.
The `http_notification_url`, if defined, will contain the following, percent encoded, query string parameters:
- `action`
- `username`
- `path`
- `local_file`, `true` if the affected file is stored on the local filesystem, otherwise `false`
- `target_path`, added for `rename` action
- `ssh_cmd`, added for `ssh_cmd` action
- `file_size`, added for `upload`, `download`, `delete` actions
The HTTP request is executed with a 15-second timeout.
The `actions` struct inside the "data_provider" configuration section allows you to configure actions on user add, update, delete.
Actions will not be fired for internal updates, such as the last login or the user quota fields, or after external authentication.
The `command`, if defined, is invoked with the following arguments:
- `action`, string, possible values are: `add`, `update`, `delete`
- `username`
- `ID`
- `status`
- `expiration_date`
- `home_dir`
- `uid`
- `gid`
The `command` can also read the following environment variables:
- `SFTPGO_USER_ACTION`
- `SFTPGO_USER_USERNAME`
- `SFTPGO_USER_PASSWORD`, hashed password as stored inside the data provider, can be empty if the user does not login using a password
- `SFTPGO_USER_ID`
- `SFTPGO_USER_STATUS`
- `SFTPGO_USER_EXPIRATION_DATE`
- `SFTPGO_USER_HOME_DIR`
- `SFTPGO_USER_UID`
- `SFTPGO_USER_GID`
- `SFTPGO_USER_QUOTA_FILES`
- `SFTPGO_USER_QUOTA_SIZE`
- `SFTPGO_USER_UPLOAD_BANDWIDTH`
- `SFTPGO_USER_DOWNLOAD_BANDWIDTH`
- `SFTPGO_USER_MAX_SESSIONS`
- `SFTPGO_USER_FS_PROVIDER`
Previous global environment variables aren't cleared when the script is called.
The `command` must finish within 15 seconds.
The `http_notification_url`, if defined, will be called invoked as http POST. The action is added to the query string, for example `<http_notification_url>?action=update`, and the user is sent serialized as JSON inside the POST body with sensitive fields removed.
The HTTP request is executed with a 15-second timeout.

40
docs/dynamic-user-mod.md Normal file
View file

@ -0,0 +1,40 @@
# Dynamic user modification
Dynamic user modification is supported via an external program that can be executed just before the user login.
To enable dynamic user modification, you must set the absolute path of your program using the `pre_login_program` key in your configuration file.
The external program can read the following environment variables to get info about the user trying to login:
- `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON
- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey` and `keyboard-interactive`
The program must write, on its the standard output, an empty string (or no response at all) if no user update is needed or the updated SFTPGo user serialized as JSON. Actions defined for users update will not be executed in this case.
The JSON response can include only the fields that need to the updated instead of the full user. For example, if you want to disable the user, you can return a response like this:
```json
{"status": 0}
```
The external program must finish within 60 seconds.
If an error happens while executing your program then login will be denied. "Dynamic user modification" and "External Authentication" are mutally exclusive.
Let's see a very basic example. Our sample program will grant access to the user `test_user` only in the time range 10:00-18:00. Other users will not be modified since the program will terminate with no output.
```
#!/bin/bash
CURRENT_TIME=`date +%H:%M`
if [[ "$SFTPGO_LOGIND_USER" =~ "\"test_user\"" ]]
then
if [[ $CURRENT_TIME > "18:00" || $CURRENT_TIME < "10:00" ]]
then
echo '{"status":0}'
else
echo '{"status":1}'
fi
fi
```
Please note that this is a demo program and it might not work in all cases. For example, the username should be obtained by parsing the JSON serialized user and not by searching the username inside the JSON as shown here.

39
docs/external-auth.md Normal file
View file

@ -0,0 +1,39 @@
# External Authentication
To enable external authentication, you must set the absolute path of your authentication program using `external_auth_program` key in your configuration file.
The external program can read the following environment variables to get info about the user trying to authenticate:
- `SFTPGO_AUTHD_USERNAME`
- `SFTPGO_AUTHD_PASSWORD`, not empty for password authentication
- `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication
- `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication
Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user.
The program must write, on its standard output, a valid SFTPGo user serialized as JSON if the authentication succeed or an user with an empty username if the authentication fails.
If the authentication succeeds, the user will be automatically added/updated inside the defined data provider. Actions defined for users added/updated will not be executed in this case.
The external program should check authentication only. If there are login restrictions such as user disabled, expired, or login allowed only from specific IP addresses, it is enough to populate the matching user fields, and these conditions will be checked in the same way as for built-in users.
The external auth program should finish very quickly. It will be killed if it does not exit within 60 seconds.
This method is slower than built-in authentication, but it's very flexible as anyone can easily write his own authentication program.
You can also restrict the authentication scope for the external program using the `external_auth_scope` configuration key:
- 0 means all supported authetication scopes. The external program will be used for password, public key and keyboard interactive authentication
- 1 means passwords only
- 2 means public keys only
- 4 means keyboard interactive only
You can combine the scopes. For example, 3 means password and public key, 5 means password and keyboard interactive, and so on.
Let's see a very basic example. Our sample authentication program will only accept user `test_user` with any password or public key.
```
#!/bin/sh
if test "$SFTPGO_AUTHD_USERNAME" = "test_user"; then
echo '{"status":1,"username":"test_user","expiration_date":0,"home_dir":"/tmp/test_user","uid":0,"gid":0,"max_sessions":0,"quota_size":0,"quota_files":100000,"permissions":{"/":["*"],"/somedir":["list","download"]},"upload_bandwidth":0,"download_bandwidth":0,"filters":{"allowed_ip":[],"denied_ip":[]},"public_keys":[]}'
else
echo '{"username":""}'
fi
```
If you have an external authentication program that could be useful for others too, please let us know and/or send a pull request.

141
docs/full-configuration.md Normal file
View file

@ -0,0 +1,141 @@
# Configuring SFTPGo
## Command line options
The SFTPGo executable can be used this way:
```
Usage:
sftpgo [command]
Available Commands:
help Help about any command
initprovider Initializes the configured data provider
portable Serve a single directory
serve Start the SFTP Server
Flags:
-h, --help help for sftpgo
-v, --version
Use "sftpgo [command] --help" for more information about a command
```
The `serve` command supports the following flags:
- `--config-dir` string. Location of the config dir. This directory should contain the configuration file and is used as the base directory for any files that use a relative path (eg. the private keys for the SFTP server, the SQLite or bblot database if you use SQLite or bbolt as data provider). The default value is "." or the value of `SFTPGO_CONFIG_DIR` environment variable.
- `--config-file` string. Name of the configuration file. It must be the name of a file stored in `config-dir`, not the absolute path to the configuration file. The specified file name must have no extension because we automatically append JSON, YAML, TOML, HCL and Java extensions when we search for the file. The default value is "sftpgo" (and therefore `sftpgo.json`, `sftpgo.yaml` and so on are searched) or the value of `SFTPGO_CONFIG_FILE` environment variable.
- `--log-compress` boolean. Determine if the rotated log files should be compressed using gzip. Default `false` or the value of `SFTPGO_LOG_COMPRESS` environment variable (1 or `true`, 0 or `false`). It is unused if `log-file-path` is empty.
- `--log-file-path` string. Location for the log file, default "sftpgo.log" or the value of `SFTPGO_LOG_FILE_PATH` environment variable. Leave empty to write logs to the standard error.
- `--log-max-age` int. Maximum number of days to retain old log files. Default 28 or the value of `SFTPGO_LOG_MAX_AGE` environment variable. It is unused if `log-file-path` is empty.
- `--log-max-backups` int. Maximum number of old log files to retain. Default 5 or the value of `SFTPGO_LOG_MAX_BACKUPS` environment variable. It is unused if `log-file-path` is empty.
- `--log-max-size` int. Maximum size in megabytes of the log file before it gets rotated. Default 10 or the value of `SFTPGO_LOG_MAX_SIZE` environment variable. It is unused if `log-file-path` is empty.
- `--log-verbose` boolean. Enable verbose logs. Default `true` or the value of `SFTPGO_LOG_VERBOSE` environment variable (1 or `true`, 0 or `false`).
If you don't configure any private host key, the daemon will use `id_rsa` and `id_ecdsa` in the configuration directory. If these files don't exist, the daemon will attempt to autogenerate them (if the user that executes SFTPGo has write access to the `config-dir`). The server supports any private key format supported by [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/keys.go#L33).
## Configuration file
The configuration file contains the following sections:
- **"sftpd"**, the configuration for the SFTP server
- `bind_port`, integer. The port used for serving SFTP requests. Default: 2022
- `bind_address`, string. Leave blank to listen on all available network interfaces. Default: ""
- `idle_timeout`, integer. Time in minutes after which an idle client will be disconnected. 0 means disabled. Default: 15
- `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts is unlimited. If set to zero, the number of attempts are limited to 6.
- `umask`, string. Umask for the new files and directories. This setting has no effect on Windows. Default: "0022"
- `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_<version>`, for example `SSH-2.0-SFTPGo_0.9.5`
- `upload_mode` integer. 0 means standard: the files are uploaded directly to the requested path. 1 means atomic: files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoids problems such as a web server that serves partial files when the files are being uploaded. In atomic mode, if there is an upload error, the temporary file is deleted and so the requested upload path will not contain a partial file. 2 means atomic with resume support: same as atomic but if there is an upload error, the temporary file is renamed to the requested path and not deleted. This way, a client can reconnect and resume the upload.
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details
- `execute_on`, list of strings. Valid values are `download`, `upload`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions.
- `command`, string. Absolute path to the command to execute. Leave empty to disable.
- `http_notification_url`, a valid URL. An HTTP GET request will be executed to this URL. Leave empty to disable.
- `keys`, struct array. It contains the daemon's private keys. If empty or missing, the daemon will search or try to generate `id_rsa` and `id_ecdsa` keys in the configuration directory.
- `private_key`, path to the private key file. It can be a path relative to the config dir or an absolute one.
- `enable_scp`, boolean. Default disabled. Set to `true` to enable the experimental SCP support. This setting is deprecated and will be removed in future versions. Please add `scp` to the `enabled_ssh_commands` list to enable it.
- `kex_algorithms`, list of strings. Available KEX (Key Exchange) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L46 "Supported kex algos")
- `ciphers`, list of strings. Allowed ciphers. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L28 "Supported ciphers")
- `macs`, list of strings. available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L84 "Supported MACs")
- `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner.
- `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored.
- `enabled_ssh_commands`, list of enabled SSH commands. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`. `*` enables all supported commands. Some commands are implemented directly inside SFTPGo, while for other commands we use system commands that need to be installed and in your system's `PATH`. For system commands we have no direct control on file creation/deletion and so we cannot support remote filesystems, such as S3, and quota check is suboptimal: if quota is enabled, the number of files is checked at the command begin and not while new files are created. The allowed size is calculated as the difference between the max quota and the used one, and it is checked against the bytes transferred via SSH. The command is aborted if it uploads more bytes than the remaining allowed size calculated at the command start. Anyway, we see the bytes that the remote command sends to the local command via SSH. These bytes contain both protocol commands and files, and so the size of the files is different from the size trasferred via SSH: for example, a command can send compressed files, or a protocol command (few bytes) could delete a big file. To mitigate this issue, quotas are recalculated at the command end with a full home directory scan. This could be heavy for big directories. If you need system commands and quotas you could consider disabling quota restrictions and periodically update quota usage yourself using the REST API. We support the following SSH commands:
- `scp`, SCP is an experimental feature, we have our own SCP implementation since we can't rely on "scp" system command to proper handle quotas and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedback before enabling it by default. We may not handle some borderline cases or sneaky bugs. Please do careful tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. SCP between two remote hosts is supported using the `-3` scp option.
- `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example, on Windows.
- `cd`, `pwd`. Some SFTP clients do not support the SFTP SSH_FXP_REALPATH packet type, so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` does nothing and `pwd` always returns the `/` path.
- `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH. They need to be installed and in your system's `PATH`. Git commands are not allowed inside virtual folders or inside directories with file extensions filters.
- `rsync`. The `rsync` command needs to be installed and in your system's `PATH`. We cannot avoid that rsync creates symlinks, so if the user has the permission to create symlinks, we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent creating symlinks that point outside the home dir. If the user cannot create symlinks, we add the option `--munge-links` if it is not already set. This should make symlinks unusable (but manually recoverable). The `rsync` command interacts with the filesystem directly and it is not aware of virtual folders and file extensions filters, so it will be automatically disabled for users with these features enabled.
- `keyboard_interactive_auth_program`, string. Absolute path to an external program to use for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details.
- `proxy_protocol`, integer. Support for [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable the proxy protocol. It provides a convenient way to safely transport connection information such as a client's address across multiple layers of NAT or TCP proxies to get the real client IP address instead of the proxy IP. Both protocol versions 1 and 2 are supported. If the proxy protocol is enabled in SFTPGo then you have to enable the protocol in your proxy configuration too. For example, for HAProxy, add `send-proxy` or `send-proxy-v2` to each server configuration line. The following modes are supported:
- 0, disabled
- 1, enabled. Proxy header will be used and requests without proxy header will be accepted
- 2, required. Proxy header will be used and requests without proxy header will be rejected
- `proxy_allowed`, List of IP addresses and IP ranges allowed to send the proxy header:
- If `proxy_protocol` is set to 1 and we receive a proxy header from an IP that is not in the list then the connection will be accepted and the header will be ignored
- If `proxy_protocol` is set to 2 and we receive a proxy header from an IP that is not in the list then the connection will be rejected
- **"data_provider"**, the configuration for the data provider
- `driver`, string. Supported drivers are `sqlite`, `mysql`, `postgresql`, `bolt`, `memory`
- `name`, string. Database name. For driver `sqlite` this can be the database name relative to the config dir or the absolute path to the SQLite database. For driver `memory` this is the (optional) path relative to the config dir or the absolute path to the users dump, obtained using the `dumpdata` REST API, to load. This dump will be loaded at startup and can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows. The `memory` provider will not modify the provided file so quota usage and last login will not be persisted
- `host`, string. Database host. Leave empty for drivers `sqlite`, `bolt` and `memory`
- `port`, integer. Database port. Leave empty for drivers `sqlite`, `bolt` and `memory`
- `username`, string. Database user. Leave empty for drivers `sqlite`, `bolt` and `memory`
- `password`, string. Database password. Leave empty for drivers `sqlite`, `bolt` and `memory`
- `sslmode`, integer. Used for drivers `mysql` and `postgresql`. 0 disable SSL/TLS connections, 1 require ssl, 2 set ssl mode to `verify-ca` for driver `postgresql` and `skip-verify` for driver `mysql`, 3 set ssl mode to `verify-full` for driver `postgresql` and `preferred` for driver `mysql`
- `connectionstring`, string. Provide a custom database connection string. If not empty, this connection string will be used instead of building one using the previous parameters. Leave empty for drivers `bolt` and `memory`
- `users_table`, string. Database table for SFTP users
- `manage_users`, integer. Set to 0 to disable users management, 1 to enable
- `track_quota`, integer. Set the preferred mode to track users quota between the following choices:
- 0, disable quota tracking. REST API to scan user dir and update quota will do nothing
- 1, quota is updated each time a user uploads or deletes a file, even if the user has no quota restrictions
- 2, quota is updated each time a user uploads or deletes a file, but only for users with quota restrictions. With this configuration, the "quota scan" REST API can still be used to periodically update space usage for users without quota restrictions
- `pool_size`, integer. Sets the maximum number of open connections for `mysql` and `postgresql` driver. Default 0 (unlimited)
- `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details
- `execute_on`, list of strings. Valid values are `add`, `update`, `delete`. `update` action will not be fired for internal updates such as the last login or the user quota fields.
- `command`, string. Absolute path to the command to execute. Leave empty to disable.
- `http_notification_url`, a valid URL. Leave empty to disable.
- `external_auth_program`, string. Absolute path to an external program to use for users authentication. See the "External Authentication" paragraph for more details. Leave empty to disable.
- `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive
- `credentials_path`, string. It defines the directory for storing user provided credential files such as Google Cloud Storage credentials. This can be an absolute path or a path relative to the config dir
- `pre_login_program`, string. Absolute path to an external program to use to modify user details just before the login. See the "Dynamic user modification" paragraph for more details. Leave empty to disable.
- **"httpd"**, the configuration for the HTTP server used to serve REST API
- `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 8080
- `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1"
- `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir
- `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir
- `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons
- `auth_user_file`, string. Path to a file used to store usernames and passwords for basic authentication. This can be an absolute path or a path relative to the config dir. We support HTTP basic authentication, and the file format must conform to the one generated using the Apache `htpasswd` tool. The supported password formats are bcrypt (`$2y$` prefix) and md5 crypt (`$apr1$` prefix). If empty, HTTP authentication is disabled.
- `certificate_file`, string. Certificate for HTTPS. This can be an absolute path or a path relative to the config dir.
- `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, the server will expect HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
A full example showing the default config (in JSON format) can be found [here](../sftpgo.json).
If you want to use a private key that use an algorithm different from RSA or ECDSA, or more private keys, then generate your own keys and replace the empty `keys` array with something like this:
```json
"keys": [
{
"private_key": "id_rsa"
},
{
"private_key": "id_ecdsa"
},
{
"private_key": "id_ed25519"
}
]
```
where `id_rsa`, `id_ecdsa` and `id_ed25519` in this example are files containing your generated keys. You can use absolute paths or paths relative to the configuration directory.
The configuration can be read from JSON, TOML, YAML, HCL, envfile and Java properties config files. If your `config-file` flag is set to `sftpgo` (default value), you need to create a configuration file called `sftpgo.json` or `sftpgo.yaml` and so on inside `config-dir`.
## Environment variables
You can also override all the available configuration options using environment variables. SFTPGo will check for environment variables with a name matching the key uppercased and prefixed with the `SFTPGO_`. You need to use `__` to traverse a struct.
Let's see some examples:
- To set sftpd `bind_port`, you need to define the env var `SFTPGO_SFTPD__BIND_PORT`
- To set the `execute_on` actions, you need to define the env var `SFTPGO_SFTPD__ACTIONS__EXECUTE_ON`. For example `SFTPGO_SFTPD__ACTIONS__EXECUTE_ON=upload,download`
Please note that, to override configuration options with environment variables, a configuration file containing the options to override is required. You can, for example, deploy the default configuration file and then override the options you need to customize using environment variables.

View file

@ -0,0 +1,11 @@
# Google Cloud Storage backend
To connect SFTPGo to Google Cloud Storage, you can use use the Application Default Credentials (ADC) strategy to try to find your application's credentials automatically or you can explicitly provide a JSON credentials file that you can obtain from the Google Cloud Console. Take a look [here](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application) for details.
Specifying a different `key_prefix`, you can assign different virtual folders of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned virtual folder and its contents. The virtual folder identified by `key_prefix` does not need to be pre-created.
You can optionally specify a [storage class](https://cloud.google.com/storage/docs/storage-classes) too. Leave it blank to use the default storage class.
The configured bucket must exist.
This backend is very similar to the [S3](./s3.md) backend, and it has the same limitations

View file

@ -0,0 +1,74 @@
# Keyboard Interactive Authentication
Keyboard interactive authentication is, in general, a series of questions asked by the server with responses provided by the client.
This authentication method is typically used for multi-factor authentication.
There are no restrictions on the number of questions asked on a particular authentication stage; there are also no restrictions on the number of stages involving different sets of questions.
To enable keyboard interactive authentication, you must set the absolute path of your authentication program using the `keyboard_interactive_auth_program` key in your configuration file.
The external program can read the following environment variables to get info about the user trying to authenticate:
- `SFTPGO_AUTHD_USERNAME`
- `SFTPGO_AUTHD_PASSWORD`, this is the hashed password as stored inside the data provider
Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters.
The program must write the questions on its standard output, in a single line, using the following struct JSON serialized:
- `instruction`, string. A short description to show to the user that is trying to authenticate. Can be empty or omitted
- `questions`, list of questions to be asked to the user
- `echos` list of boolean flags corresponding to the questions (so the lengths of both lists must be the same) and indicating whether user's reply for a particular question should be echoed on the screen while they are typing: true if it should be echoed, or false if it should be hidden.
- `check_password` optional integer. Ask exactly one question and set this field to 1 if the expected answer is the user password and you want SFTPGo to check it for you. If the password is correct, the returned response to the program is `OK`. If the password is wrong, the program will be terminated and an authentication error will be returned to the user that is trying to authenticate.
- `auth_result`, integer. Set this field to 1 to indicate successful authentication. 0 is ignored. Any other value means authentication error. If this field is found and it is different from 0 then SFTPGo will not read any other questions from the external program, and it will finalize the authentication.
SFTPGo writes the user answers to the program standard input, one per line, in the same order as the questions.
Please be sure that your program receives the answers for all the issued questions before asking for the next ones.
Keyboard interactive authentication can be chained to the external authentication.
The authentication must finish within 60 seconds.
Let's see a very basic example. Our sample keyboard interactive authentication program will ask for 2 sets of questions and accept the user if the answer to the last question is `answer3`.
```
#!/bin/sh
echo '{"questions":["Question1: ","Question2: "],"instruction":"This is a sample for keyboard interactive authentication","echos":[true,false]}'
read ANSWER1
read ANSWER2
echo '{"questions":["Question3: "],"instruction":"","echos":[true]}'
read ANSWER3
if test "$ANSWER3" = "answer3"; then
echo '{"auth_result":1}'
else
echo '{"auth_result":-1}'
fi
```
and here is an example where SFTPGo checks the user password for you:
```
#!/bin/sh
echo '{"questions":["Password: "],"instruction":"This is a sample for keyboard interactive authentication","echos":[false],"check_password":1}'
read ANSWER1
if test "$ANSWER1" != "OK"; then
exit 1
fi
echo '{"questions":["One time token: "],"instruction":"","echos":[false]}'
read ANSWER2
if test "$ANSWER2" = "token"; then
echo '{"auth_result":1}'
else
echo '{"auth_result":-1}'
fi
```

54
docs/logs.md Normal file
View file

@ -0,0 +1,54 @@
# Logs
The log file is a stream of JSON structs. Each struct has a `sender` field that identifies the log type.
The logs can be divided into the following categories:
- **"app logs"**, internal logs used to debug SFTPGo:
- `sender` string. This is generally the package name that emits the log
- `time` string. Date/time with millisecond precision
- `level` string
- `message` string
- **"transfer logs"**, SFTP/SCP transfer logs:
- `sender` string. `Upload` or `Download`
- `time` string. Date/time with millisecond precision
- `level` string
- `elapsed_ms`, int64. Elapsed time, as milliseconds, for the upload/download
- `size_bytes`, int64. Size, as bytes, of the download/upload
- `username`, string
- `file_path` string
- `connection_id` string. Unique connection identifier
- `protocol` string. `SFTP` or `SCP`
- **"command logs"**, SFTP/SCP command logs:
- `sender` string. `Rename`, `Rmdir`, `Mkdir`, `Symlink`, `Remove`, `Chmod`, `Chown`, `Chtimes`, `SSHCommand`
- `level` string
- `username`, string
- `file_path` string
- `target_path` string
- `filemode` string. Valid for sender `Chmod` otherwise empty
- `uid` integer. Valid for sender `Chown` otherwise -1
- `gid` integer. Valid for sender `Chown` otherwise -1
- `access_time` datetime as YYYY-MM-DDTHH:MM:SS. Valid for sender `Chtimes` otherwise empty
- `modification_time` datetime as YYYY-MM-DDTHH:MM:SS. Valid for sender `Chtimes` otherwise empty
- `ssh_command`, string. Valid for sender `SSHCommand` otherwise empty
- `connection_id` string. Unique connection identifier
- `protocol` string. `SFTP`, `SCP` or `SSH`
- **"http logs"**, REST API logs:
- `sender` string. `httpd`
- `level` string
- `remote_addr` string. IP and port of the remote client
- `proto` string, for example `HTTP/1.1`
- `method` string. HTTP method (`GET`, `POST`, `PUT`, `DELETE` etc.)
- `user_agent` string
- `uri` string. Full uri
- `resp_status` integer. HTTP response status code
- `resp_size` integer. Size in bytes of the HTTP response
- `elapsed_ms` int64. Elapsed time, as milliseconds, to complete the request
- `request_id` string. Unique request identifier
- **"connection failed logs"**, logs for failed attempts to initialize a connection. A connection can fail for an authentication error or other errors such as a client abort or a timeout if the login does not happen in two minutes
- `sender` string. `connection_failed`
- `level` string
- `username`, string. Can be empty if the connection is closed before an authentication attempt
- `client_ip` string.
- `login_type` string. Can be `publickey`, `password`, `keyboard-interactive` or `no_auth_tryed`
- `error` string. Optional error description

18
docs/metrics.md Normal file
View file

@ -0,0 +1,18 @@
# Metrics
SFTPGo exposes [Prometheus](https://prometheus.io/) metrics at the `/metrics` HTTP endpoint.
Several counters and gauges are available, for example:
- Total uploads and downloads
- Total upload and download size
- Total upload and download errors
- Total executed SSH commands
- Total SSH command errors
- Number of active connections
- Data provider availability
- Total successful and failed logins using password, public key or keyboard interactive authentication
- Total HTTP requests served and totals for response code
- Go's runtime details about GC, number of gouroutines and OS threads
- Process information like CPU, memory, file descriptor usage and start time
Please check the `/metrics` page for more details.

180
docs/performance.md Normal file
View file

@ -0,0 +1,180 @@
# Performance
SFTPGo can easily saturate a Gigabit connection on low end hardware with no special configuration, this is generally enough for most use cases.
For Multi-Gig connections, some performance improvements and comparisons with OpenSSH have been discussed [here](https://github.com/drakkan/sftpgo/issues/69), some of them need upstream updates so there are not included in the SFTPGo released version (0.9.6) yet. To summarize:
- In current state with all performance improvements applied, SFTP performance is very close to OpenSSH however CPU usage is higher. SCP performance match OpenSSH.
- The main bottlenecks are the encryption and the messages authentication, so if you can use a fast cipher with implicit messages authentication, such as `aes128-gcm@openssh.com`, you will get a big performance boost.
- SCP protocol is much simpler than SFTP and so, the multi-platform, SFTPGo's SCP implementation performs better than SFTP.
- Load balancing with HAProxy can greatly improve the performance if CPU not become the bottleneck.
## Benchmark
### Hardware specification
**Server** ||
--- | --- |
OS| Debian 10.2 x64 |
CPU| Ryzen5 3600 |
RAM| 64GB 2400MHz ECC |
Disk| 3* Intel P4510 4TB RAID0 |
Ethernet| Mellanox ConnectX-3 40GbE|
**Client** ||
--- | --- |
OS| Ubuntu 19.10 x64 |
CPU| Threadripper 1920X |
RAM| 64GB 2400MHz ECC |
Disk| Samsung 960EVO 1TB |
Ethernet| Mellanox ConnectX-3 40GbE|
### Test configurations
- `Baseline`: SFTPGo version 0.9.6.
- `Optimized`: Various [optimizations](#Optimizations-applied) applied on top of 0.9.6.
- `Balanced`: Two optimized instances, running on localhost, load balanced by HAProxy 2.1.3.
- `OpenSSH`: OpenSSH_7.9p1 Debian-10+deb10u2, OpenSSL 1.1.1d 10 Sep 2019
Server's CPU is in Eco mode, you can expect better results in certain cases with a stronger CPU, especially multi-stream HAProxy balanced load.
#### Cipher aes128-ctr
The Message Authentication Code (MAC) used is `hmac-sha2-256`.
##### SFTP
Download:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|149|306|370|378|
2|265|576|675|720|
3|341|807|880|1002|
4|402|1024|1150|1222|
8|518|1749|1400|1815|
Upload:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|165|292|340|355|
2|262|453|490|633|
3|327|566|560|726|
4|376|647|650|788|
8|478|735|700|806|
##### SCP
Download:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|215|509|579|470|
2|433|928|1097|828|
3|613|1327|1346|1174|
4|824|1670|1576|1424|
8|1281|2656|2049|1870|
Upload:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|189|354|393|428|
2|312|510|570|668|
3|401|621|664|803|
4|481|705|723|840|
8|652|767|799|884|
#### Cipher aes128gcm@openssh.com
With this cipher the messages authentication is implicit, no SHA256 computation is needed.
##### SFTP
Download:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|322|388|561|401|
2|518|734|976|810|
3|638|1067|1214|1072|
4|723|1283|1415|1288|
8|844|2072|1742|1842|
Upload:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|318|353|415|381|
2|473|542|573|670|
3|569|672|657|757|
4|621|730|721|758|
8|694|825|763|815|
##### SCP
Download:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|669|734|818|447|
2|1218|1320|1367|883|
3|1752|1738|1755|1217|
4|2202|2238|2038|1475|
8|3151|3184|2391|1941|
Upload:
Stream|Baseline MB/s|Optimized MB/s|Balanced MB/s|OpenSSH MB/s|
---|---|---|---|---|
1|446|446|494|448|
2|616|623|646|650|
3|746|749|728|741|
4|833|832|788|858|
8|897|903|823|887|
### Optimizations applied
- AES-CTR optimization of Golang compiler, the patch hasn't been merged yet, you can apply it yourself. [Patch](https://go-review.googlesource.com/c/go/+/51670)
- Use [minio/sha256-simd](https://github.com/minio/sha256-simd) to accelerate MAC (Message Authentication Code) computation. In this way the tested hardware will use `Intel SHA Extensions` for SHA256 computation. This will give a significant performance boost compared to `AVX2` extensions used with the Golang's SHA256 implementation.
```
diff --git a/go.mod b/go.mod
index f1b2caa..109e064 100644
--- a/go.mod
+++ b/go.mod
@@ -43,3 +43,5 @@ require (
)
replace github.com/eikenb/pipeat v0.0.0-20190316224601-fb1f3a9aa29f => github.com/drakkan/pipeat v0.0.0-20200123131427-11c048cfc0ec
+
+replace golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20200303175438-17ef3d252b1c
```
- A new allocator for `pkg/sftp` which greatly improve parallel loads. We are discussing about this patch with `pkg/sftp` maintainers [here](https://github.com/pkg/sftp/issues/334).
```
diff --git a/go.mod b/go.mod
index 109e064..4d67a47 100644
--- a/go.mod
+++ b/go.mod
@@ -45,3 +45,4 @@ require (
replace github.com/eikenb/pipeat v0.0.0-20190316224601-fb1f3a9aa29f => github.com/drakkan/pipeat v0.0.0-20200123131427-11c048cfc0ec
replace golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20200303175438-17ef3d252b1c
+replace github.com/pkg/sftp => github.com/drakkan/sftp v0.0.0-20200227085621-6b4abaad1b9a
```
### HAProxy configuration
Here is the relevant HAProxy configuration used for the `Balanced` test configuration:
```
frontend sftp
bind :2222
mode tcp
timeout client 600s
default_backend sftpgo
backend sftpgo
mode tcp
balance roundrobin
timeout connect 10s
timeout server 600s
timeout queue 30s
option tcp-check
tcp-check expect string SSH-2.0-
server sftpgo1 127.0.0.1:2022 check send-proxy-v2 weight 10 inter 10s rise 2 fall 3
server sftpgo2 127.0.0.1:2024 check send-proxy-v2 weight 10 inter 10s rise 2 fall 3
```

57
docs/portable-mode.md Normal file
View file

@ -0,0 +1,57 @@
# Portable mode
SFTPGo allows to share a single directory on demand using the `portable` subcommand:
```
sftpgo portable --help
To serve the current working directory with auto generated credentials simply use:
sftpgo portable
Please take a look at the usage below to customize the serving parameters
Usage:
sftpgo portable [flags]
Flags:
-C, --advertise-credentials If the SFTP service is advertised via multicast DNS, this flag allows to put username/password inside the advertised TXT record
-S, --advertise-service Advertise SFTP service using multicast DNS (default true)
--allowed-extensions stringArray Allowed file extensions case insensitive. The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png"
--denied-extensions stringArray Denied file extensions case insensitive. The format is /dir::ext1,ext2. For example: "/somedir::.jpg,.png"
-d, --directory string Path to the directory to serve. This can be an absolute path or a path relative to the current directory (default ".")
-f, --fs-provider int 0 means local filesystem, 1 Amazon S3 compatible, 2 Google Cloud Storage
--gcs-automatic-credentials int 0 means explicit credentials using a JSON credentials file, 1 automatic (default 1)
--gcs-bucket string
--gcs-credentials-file string Google Cloud Storage JSON credentials file
--gcs-key-prefix string Allows to restrict access to the virtual folder identified by this prefix and its contents
--gcs-storage-class string
-h, --help help for portable
-l, --log-file-path string Leave empty to disable logging
-p, --password string Leave empty to use an auto generated value
-g, --permissions strings User's permissions. "*" means any permission (default [list,download])
-k, --public-key strings
--s3-access-key string
--s3-access-secret string
--s3-bucket string
--s3-endpoint string
--s3-key-prefix string Allows to restrict access to the virtual folder identified by this prefix and its contents
--s3-region string
--s3-storage-class string
-s, --sftpd-port int 0 means a random non privileged port
-c, --ssh-commands strings SSH commands to enable. "*" means any supported SSH command including scp (default [md5sum,sha1sum,cd,pwd])
-u, --username string Leave empty to use an auto generated value
```
In portable mode, SFTPGo can advertise the SFTP service and, optionally, the credentials via multicast DNS, so there is a standard way to discover the service and to automatically connect to it.
Here is an example of the advertised service including credentials as seen using `avahi-browse`:
```
= enp0s31f6 IPv4 SFTPGo portable 53705 SFTP File Transfer local
hostname = [p1.local]
address = [192.168.1.230]
port = [53705]
txt = ["password=EWOo6pJe" "user=user" "version=0.9.3-dev-b409523-dirty-2019-10-26T13:43:32Z"]
```

35
docs/rest-api.md Normal file
View file

@ -0,0 +1,35 @@
# REST API
SFTPGo exposes REST API to manage, backup, and restore users, and to get real time reports of the active connections with the ability to forcibly close a connection.
If quota tracking is enabled in the configuration file, then the used size and number of files are updated each time a file is added/removed. If files are added/removed not using SFTP/SCP, or if you change `track_quota` from `2` to `1`, you can rescan the users home dir and update the used quota using the REST API.
REST API can be protected using HTTP basic authentication and exposed via HTTPS. If you need more advanced security features, you can setup a reverse proxy using an HTTP Server such as Apache or NGNIX.
For example, you can keep SFTPGo listening on localhost and expose it externally configuring a reverse proxy using Apache HTTP Server this way:
```
ProxyPass /api/v1 http://127.0.0.1:8080/api/v1
ProxyPassReverse /api/v1 http://127.0.0.1:8080/api/v1
```
and you can add authentication with something like this:
```
<Location /api/v1>
AuthType Digest
AuthName "Private"
AuthDigestDomain "/api/v1"
AuthDigestProvider file
AuthUserFile "/etc/httpd/conf/auth_digest"
Require valid-user
</Location>
```
and, of course, you can configure the web server to use HTTPS.
The OpenAPI 3 schema for the exposed API can be found inside the source tree: [openapi.yaml](../httpd/schema/openapi.yaml "OpenAPI 3 specs").
A sample CLI client for the REST API can be found inside the source tree [scripts](../scripts "scripts") directory.
You can also generate your own REST client in your preferred programming language, or even bash scripts, using an OpenAPI generator such as [swagger-codegen](https://github.com/swagger-api/swagger-codegen) or [OpenAPI Generator](https://openapi-generator.tech/)

30
docs/s3.md Normal file
View file

@ -0,0 +1,30 @@
# S3 Compabible Object Storage backends
To connect SFTPGo to AWS, you need to specify credentials, a `bucket` and a `region`. Here is the list of available [AWS regions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions). For example, if your bucket is at `Frankfurt`, you have to set the region to `eu-central-1`. You can specify an AWS [storage class](https://docs.aws.amazon.com/AmazonS3/latest/dev/storage-class-intro.html) too. Leave it blank to use the default AWS storage class. An endpoint is required if you are connecting to a Compatible AWS Storage such as [MinIO](https://min.io/).
AWS SDK has different options for credentials. [More Detail](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html). We support:
1. Providing [Access Keys](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys).
2. Use IAM roles for Amazon EC2
3. Use IAM roles for tasks if your application uses an ECS task definition
So, you need to provide access keys to activate option 1, or leave them blank to use the other ways to specify credentials.
Specifying a different `key_prefix`, you can assign different virtual folders of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned virtual folder and its contents. The virtual folder identified by `key_prefix` does not need to be pre-created.
SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3.
The configured bucket must exist.
Some SFTP commands don't work over S3:
- `symlink` and `chtimes` will fail
- `chown` and `chmod` are silently ignored
- upload resume is not supported
- upload mode `atomic` is ignored since S3 uploads are already atomic
Other notes:
- `rename` is a two step operation: server-side copy and then deletion. So, it is not atomic as for local filesystem.
- We don't support renaming non empty directories since we should rename all the contents too and this could take a long time: think about directories with thousands of files; for each file we should do an AWS API call.
- For server side encryption, you have to configure the mapped bucket to automatically encrypt objects.
- A local home directory is still required to store temporary files.

109
docs/service.md Normal file
View file

@ -0,0 +1,109 @@
# Running SFTPGo as a service
## Linux
For Linux, a `systemd` sample [service](../init/sftpgo.service "systemd service") can be found inside the source tree.
Here are some basic instructions to run SFTPGo as service, please run the following commands from the directory where you downloaded SFTPGo:
```bash
# create the required directories
sudo mkdir -p /etc/sftpgo \
/var/lib/sftpgo
# install sftpgo executable
sudo install -Dm755 sftpgo /usr/bin/sftpgo
# install the default configuration file, edit it if required
sudo install -Dm644 sftpgo.json /etc/sftpgo/
# override some configuration keys using environment variables
sudo sh -c 'echo "SFTPGO_HTTPD__TEMPLATES_PATH=/var/lib/sftpgo/templates" > /etc/sftpgo/sftpgo.env'
sudo sh -c 'echo "SFTPGO_HTTPD__STATIC_FILES_PATH=/var/lib/sftpgo/static" >> /etc/sftpgo/sftpgo.env'
sudo sh -c 'echo "SFTPGO_HTTPD__BACKUPS_PATH=/var/lib/sftpgo/backups" >> /etc/sftpgo/sftpgo.env'
sudo sh -c 'echo "SFTPGO_DATA_PROVIDER__CREDENTIALS_PATH=/var/lib/sftpgo/credentials" >> /etc/sftpgo/sftpgo.env'
# install static files and templates for the web UI
sudo cp -r static templates /var/lib/sftpgo/
# initialize the configured data provider
# if you want to use MySQL or PostgreSQL you need to create the configured database before running the initprovider command
sudo /usr/bin/sftpgo initprovider -c /etc/sftpgo/
# install the systemd service
sudo install -Dm644 init/sftpgo.service /etc/systemd/system
# start the service
sudo systemctl start sftpgo
# verify that the service is started
sudo systemctl status sftpgo
# automatically start sftpgo on boot
sudo systemctl enable sftpgo
# optional, install the REST API CLI. It requires python-requests to run
sudo install -Dm755 scripts/sftpgo_api_cli.py /usr/bin/sftpgo_api_cli
```
## macOS
For macOS, a `launchd` sample [service](../init/com.github.drakkan.sftpgo.plist "launchd plist") can be found inside the source tree. The `launchd` plist assumes that SFTPGo has `/usr/local/opt/sftpgo` as base directory.
Here are some basic instructions to run SFTPGo as service, please run the following commands from the directory where you downloaded SFTPGo:
```bash
# create the required directories
sudo mkdir -p /usr/local/opt/sftpgo/init \
/usr/local/opt/sftpgo/var/lib \
/usr/local/opt/sftpgo/var/log \
/usr/local/opt/sftpgo/etc \
/usr/local/opt/sftpgo/bin
# install sftpgo executable
sudo cp sftpgo /usr/local/opt/sftpgo/bin/
# install the launchd service
sudo cp init/com.github.drakkan.sftpgo.plist /usr/local/opt/sftpgo/init/
sudo chown root:wheel /usr/local/opt/sftpgo/init/com.github.drakkan.sftpgo.plist
# install the default configuration file, edit it if required
sudo cp sftpgo.json /usr/local/opt/sftpgo/etc/
# install static files and templates for the web UI
sudo cp -r static templates /usr/local/opt/sftpgo/var/lib/
# initialize the configured data provider
# if you want to use MySQL or PostgreSQL you need to create the configured database before running the initprovider command
sudo /usr/local/opt/sftpgo/bin/sftpgo initprovider -c /usr/local/opt/sftpgo/etc/
# add sftpgo to the launch daemons
sudo ln -s /usr/local/opt/sftpgo/init/com.github.drakkan.sftpgo.plist /Library/LaunchDaemons/com.github.drakkan.sftpgo.plist
# start the service and enable it to start on boot
sudo launchctl load -w /Library/LaunchDaemons/com.github.drakkan.sftpgo.plist
# verify that the service is started
sudo launchctl list com.github.drakkan.sftpgo
# optional, install the REST API CLI. It requires python-requests to run, this python module is not installed by default
sudo cp scripts/sftpgo_api_cli.py /usr/local/opt/sftpgo/bin/
```
## Windows
On Windows, you can register SFTPGo as Windows Service. Take a look at the CLI usage to learn how to do this:
```powershell
PS> sftpgo.exe service --help
Install, Uninstall, Start, Stop, Reload and retrieve status for SFTPGo Windows Service
Usage:
sftpgo service [command]
Available Commands:
install Install SFTPGo as Windows Service
reload Reload the SFTPGo Windows Service sending a `paramchange` request
start Start SFTPGo Windows Service
status Retrieve the status for the SFTPGo Windows Service
stop Stop SFTPGo Windows Service
uninstall Uninstall SFTPGo Windows Service
Flags:
-h, --help help for service
Use "sftpgo service [command] --help" for more information about a command.
```
The `install` subcommand accepts the same flags that are valid for `serve`.
After installing as a Windows Service, please remember to allow network access to the SFTPGo executable using something like this:
```powershell
PS> netsh advfirewall firewall add rule name="SFTPGo Service" dir=in action=allow program="C:\Program Files\SFTPGo\sftpgo.exe"
```
(Or through the Windows Firewall GUI.)

8
docs/web-admin.md Normal file
View file

@ -0,0 +1,8 @@
# Web Admin
You can easily build your own interface using the exposed REST API. Anyway, SFTPGo also provides a very basic built-in web interface that allows you to manage users and connections.
With the default `httpd` configuration, the web admin is available at the following URL:
[http://127.0.0.1:8080/web](http://127.0.0.1:8080/web)
The web interface can be protected using HTTP basic authentication and exposed via HTTPS. If you need more advanced security features, you can setup a reverse proxy as explained for the [REST API](./rest-api.md).

View file

@ -1,60 +0,0 @@
# Authy
These example show how-to integrate [Twilio Authy API](https://www.twilio.com/docs/authy/api) for One-Time-Password logins.
The examples assume that the user has the free [Authy app](https://authy.com/) installed and uses it to generate offline [TOTP](https://en.wikipedia.org/wiki/Time-based_One-time_Password_algorithm) codes (soft tokens).
You first need to [create an Authy Application in the Twilio Console](https://twilio.com/console/authy/applications?_ga=2.205553366.451688189.1597667213-1526360003.1597667213), then you can create a new Authy user and store a reference to the matching SFTPGo account.
Verify that your Authy application is successfully registered:
```bash
export AUTHY_API_KEY=<your api key here>
curl 'https://api.authy.com/protected/json/app/details' -H "X-Authy-API-Key: $AUTHY_API_KEY"
```
now create an Authy user:
```bash
curl -XPOST "https://api.authy.com/protected/json/users/new" \
-H "X-Authy-API-Key: $AUTHY_API_KEY" \
--data-urlencode user[email]="user@domain.com" \
--data-urlencode user[cellphone]="317-338-9302" \
--data-urlencode user[country_code]="54"
```
The response is something like this:
```json
{"message":"User created successfully.","user":{"id":xxxxxxxx},"success":true}
```
Save the user id somewhere and add a reference to the matching SFTPGo account. You could also store this ID in the `additional_info` SFTPGo user field.
After this step you can use the Authy app installed on your phone to generate TOTP codes.
Now you can verify the token using an HTTP GET request:
```bash
export TOKEN=<TOTP you read from Authy app>
export AUTHY_ID=<user id>
curl -i "https://api.authy.com/protected/json/verify/${TOKEN}/${AUTHY_ID}" \
-H "X-Authy-API-Key: $AUTHY_API_KEY"
```
So inside your hook you need to check:
- the HTTP response code for the verify request, it must be `200`
- the JSON response body, it must contains the key `success` with the value `true` (as string)
If these conditions are met the token is valid and you allow the user to login.
We provide the following examples:
- [Keyboard interactive authentication](./keyint/README.md) for 2FA using password + Authy one time token.
- [External authentication](./extauth/README.md) using Authy one time tokens as passwords.
- [Check password hook](./checkpwd/README.md) for 2FA using a password consisting of a fixed string and a One Time Token.
Please note that these are sample programs not intended for production use, you should write your own hook based on them and you should prefer HTTP based hooks if performance is a concern.
:warning: SFTPGo has also built-in 2FA support.

View file

@ -1,3 +0,0 @@
# Authy 2FA via check password hook
This example shows how to use 2FA via the check password hook using a password consisting of a fixed part and an Authy TOTP token. The hook will check the TOTP token using the Authy API and SFTPGo will check the fixed part. Please read the [sample code](./main.go), it should be self explanatory.

View file

@ -1,3 +0,0 @@
module github.com/drakkan/sftpgo/authy/checkpwd
go 1.22.2

View file

@ -1,106 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
type userMapping struct {
SFTPGoUsername string
AuthyID int64
AuthyAPIKey string
}
type checkPasswordResponse struct {
// 0 KO, 1 OK, 2 partial success
Status int `json:"status"`
// for status == 2 this is the password that SFTPGo will check against the one stored
// inside the data provider
ToVerify string `json:"to_verify"`
}
var (
mapping []userMapping
)
func init() {
// this is for demo only, you probably want to get this mapping dynamically, for example using a database query
mapping = append(mapping, userMapping{
SFTPGoUsername: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}
func printResponse(status int, toVerify string) {
r := checkPasswordResponse{
Status: status,
ToVerify: toVerify,
}
resp, _ := json.Marshal(r)
fmt.Printf("%v\n", string(resp))
if status > 0 {
os.Exit(0)
} else {
os.Exit(1)
}
}
func main() {
// get credentials from env vars
username := os.Getenv("SFTPGO_AUTHD_USERNAME")
password := os.Getenv("SFTPGO_AUTHD_PASSWORD")
for _, m := range mapping {
if m.SFTPGoUsername == username {
// Authy token len is 7, we assume that we have the password followed by the token
pwdLen := len(password)
if pwdLen <= 7 {
printResponse(0, "")
}
pwd := password[:pwdLen-7]
authyToken := password[pwdLen-7:]
// now verify the authy token and instruct SFTPGo to check the password if the token is OK
url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", authyToken, m.AuthyID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Authy-API-Key", m.AuthyAPIKey)
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := httpClient.Do(req)
if err != nil {
printResponse(0, "")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// status code 200 is expected
printResponse(0, "")
}
var authyResponse map[string]interface{}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
printResponse(0, "")
}
err = json.Unmarshal(respBody, &authyResponse)
if err != nil {
printResponse(0, "")
}
if authyResponse["success"].(string) == "true" {
printResponse(2, pwd)
}
printResponse(0, "")
break
}
}
// no mapping found
printResponse(0, "")
}

View file

@ -1,3 +0,0 @@
# Authy external authentication
This example shows how to use Authy TOTP token as password for SFTPGo users. Please read the [sample code](./main.go), it should be self explanatory.

View file

@ -1,3 +0,0 @@
module github.com/drakkan/sftpgo/authy/extauth
go 1.22.2

View file

@ -1,109 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
type userMapping struct {
SFTPGoUsername string
AuthyID int64
AuthyAPIKey string
}
// we assume that the SFTPGo already exists, we only check the one time token.
// If you need to create the SFTPGo user more fields are needed here
type minimalSFTPGoUser struct {
Status int `json:"status,omitempty"`
Username string `json:"username"`
HomeDir string `json:"home_dir,omitempty"`
Permissions map[string][]string `json:"permissions"`
}
var (
mapping []userMapping
)
func init() {
// this is for demo only, you probably want to get this mapping dynamically, for example using a database query
mapping = append(mapping, userMapping{
SFTPGoUsername: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}
func printResponse(username string) {
u := minimalSFTPGoUser{
Username: username,
Status: 1,
HomeDir: filepath.Join(os.TempDir(), username),
}
u.Permissions = make(map[string][]string)
u.Permissions["/"] = []string{"*"}
resp, _ := json.Marshal(u)
fmt.Printf("%v\n", string(resp))
if len(username) > 0 {
os.Exit(0)
} else {
os.Exit(1)
}
}
func main() {
// get credentials from env vars
username := os.Getenv("SFTPGO_AUTHD_USERNAME")
password := os.Getenv("SFTPGO_AUTHD_PASSWORD")
if len(password) == 0 {
// login method is not password
printResponse("")
return
}
for _, m := range mapping {
if m.SFTPGoUsername == username {
// mapping found we can now verify the token
url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", password, m.AuthyID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Authy-API-Key", m.AuthyAPIKey)
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := httpClient.Do(req)
if err != nil {
printResponse("")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// status code 200 is expected
printResponse("")
}
var authyResponse map[string]interface{}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
printResponse("")
}
err = json.Unmarshal(respBody, &authyResponse)
if err != nil {
printResponse("")
}
if authyResponse["success"].(string) == "true" {
printResponse(username)
}
printResponse("")
break
}
}
// no mapping found
printResponse("")
}

View file

@ -1,3 +0,0 @@
# Authy 2FA using keyboard interactive authentication
This example shows how to authenticate SFTP users using 2FA (password + Authy token). Please read the [sample code](./main.go), it should be self explanatory.

View file

@ -1,3 +0,0 @@
module github.com/drakkan/sftpgo/authy/keyint
go 1.22.2

View file

@ -1,137 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type userMapping struct {
SFTPGoUsername string
AuthyID int64
AuthyAPIKey string
}
type keyboardAuthHookResponse struct {
Instruction string `json:"instruction,omitempty"`
Questions []string `json:"questions,omitempty"`
Echos []bool `json:"echos,omitempty"`
AuthResult int `json:"auth_result"`
CheckPwd int `json:"check_password,omitempty"`
}
var (
mapping []userMapping
)
func init() {
// this is for demo only, you probably want to get this mapping dynamically, for example using a database query
mapping = append(mapping, userMapping{
SFTPGoUsername: "<SFTPGo username>",
AuthyID: 1234567,
AuthyAPIKey: "<your api key>",
})
}
func printAuthResponse(result int) {
resp, _ := json.Marshal(keyboardAuthHookResponse{
AuthResult: result,
})
fmt.Printf("%v\n", string(resp))
if result == 1 {
os.Exit(0)
} else {
os.Exit(1)
}
}
func main() {
// get credentials from env vars
username := os.Getenv("SFTPGO_AUTHD_USERNAME")
var userMap userMapping
for _, m := range mapping {
if m.SFTPGoUsername == username {
userMap = m
break
}
}
if userMap.SFTPGoUsername != username {
// no mapping found
os.Exit(1)
}
checkPwdQuestion := keyboardAuthHookResponse{
Instruction: "This is a sample keyboard authentication program that ask for your password + Authy token",
Questions: []string{"Your password: "},
Echos: []bool{false},
CheckPwd: 1,
AuthResult: 0,
}
q, _ := json.Marshal(checkPwdQuestion)
fmt.Printf("%v\n", string(q))
// in a real world app you probably want to use a read timeout
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
if scanner.Err() != nil {
printAuthResponse(-1)
}
response := scanner.Text()
if response != "OK" {
printAuthResponse(-1)
}
checkTokenQuestion := keyboardAuthHookResponse{
Instruction: "",
Questions: []string{"Authy token: "},
Echos: []bool{false},
CheckPwd: 0,
AuthResult: 0,
}
q, _ = json.Marshal(checkTokenQuestion)
fmt.Printf("%v\n", string(q))
scanner.Scan()
if scanner.Err() != nil {
printAuthResponse(-1)
}
authyToken := scanner.Text()
url := fmt.Sprintf("https://api.authy.com/protected/json/verify/%v/%v", authyToken, userMap.AuthyID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
printAuthResponse(-1)
}
req.Header.Set("X-Authy-API-Key", userMap.AuthyAPIKey)
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := httpClient.Do(req)
if err != nil {
printAuthResponse(-1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// status code 200 is expected
printAuthResponse(-1)
}
var authyResponse map[string]interface{}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
printAuthResponse(-1)
}
err = json.Unmarshal(respBody, &authyResponse)
if err != nil {
printAuthResponse(-1)
}
if authyResponse["success"].(string) == "true" {
printAuthResponse(1)
}
printAuthResponse(-1)
}

View file

@ -1,19 +0,0 @@
# Data Backup
:warning: Since v2.4.0 you can use the [EventManager](https://docs.sftpgo.com/latest/eventmanager/) to schedule backups.
The `backup` example script shows how to use the SFTPGo REST API to backup your data.
The script is written in Python and has the following requirements:
- python3 or python2
- python [Requests](https://requests.readthedocs.io/en/master/) module
The provided example tries to connect to an SFTPGo instance running on `127.0.0.1:8080` using the following credentials:
- username: `admin`
- password: `password`
and, if you execute it daily, it saves a different backup file for each day of the week. The backups will be saved within the configured `backups_path`.
Please edit the script according to your needs.

View file

@ -1,36 +0,0 @@
#!/usr/bin/env python
from datetime import datetime
import sys
import requests
try:
import urllib.parse as urlparse
except ImportError:
import urlparse
# change base_url to point to your SFTPGo installation
base_url = "http://127.0.0.1:8080"
# set to False if you want to skip TLS certificate validation
verify_tls_cert = True
# set the credentials for a valid admin here
admin_user = "admin"
admin_password = "password"
# get a JWT token
auth = requests.auth.HTTPBasicAuth(admin_user, admin_password)
r = requests.get(urlparse.urljoin(base_url, "api/v2/token"), auth=auth, verify=verify_tls_cert, timeout=10)
if r.status_code != 200:
print("error getting access token: {}".format(r.text))
sys.exit(1)
access_token = r.json()["access_token"]
auth_header = {"Authorization": "Bearer " + access_token}
r = requests.get(urlparse.urljoin(base_url, "api/v2/dumpdata"),
params={"output-file":"backup_{}.json".format(datetime.today().strftime('%w'))},
headers=auth_header, verify=verify_tls_cert, timeout=10)
if r.status_code == 200:
print("backup OK")
else:
print("backup error, status {}, response: {}".format(r.status_code, r.text))

View file

@ -1,17 +0,0 @@
# Bulk user update
The `bulkuserupdate` example script shows how to use the SFTPGo REST API to easily update some common parameters for multiple users while preserving the others.
The script is written in Python and has the following requirements:
- python3 or python2
- python [Requests](https://requests.readthedocs.io/en/master/) module
The provided example tries to connect to an SFTPGo instance running on `127.0.0.1:8080` using the following credentials:
- username: `admin`
- password: `password`
and it updates some fields for `user1`, `user2` and `user3`.
Please edit the script according to your needs.

View file

@ -1,49 +0,0 @@
#!/usr/bin/env python
import posixpath
import sys
import requests
try:
import urllib.parse as urlparse
except ImportError:
import urlparse
# change base_url to point to your SFTPGo installation
base_url = "http://127.0.0.1:8080"
# set to False if you want to skip TLS certificate validation
verify_tls_cert = True
# set the credentials for a valid admin here
admin_user = "admin"
admin_password = "password"
# insert here the users you want to update
users_to_update = ["user1", "user2", "user3"]
# set here the fields you need to update
fields_to_update = {"status":0, "quota_files": 1000, "additional_info":"updated using the bulkuserupdate example script"}
# get a JWT token
auth = requests.auth.HTTPBasicAuth(admin_user, admin_password)
r = requests.get(urlparse.urljoin(base_url, "api/v2/token"), auth=auth, verify=verify_tls_cert, timeout=10)
if r.status_code != 200:
print("error getting access token: {}".format(r.text))
sys.exit(1)
access_token = r.json()["access_token"]
auth_header = {"Authorization": "Bearer " + access_token}
for username in users_to_update:
r = requests.get(urlparse.urljoin(base_url, posixpath.join("api/v2/users", username)),
headers=auth_header, verify=verify_tls_cert, timeout=10)
if r.status_code != 200:
print("error getting user {}: {}".format(username, r.text))
continue
user = r.json()
user.update(fields_to_update)
r = requests.put(urlparse.urljoin(base_url, posixpath.join("api/v2/users", username)),
headers=auth_header, verify=verify_tls_cert, json=user, timeout=10)
if r.status_code == 200:
print("user {} updated".format(username))
else:
print("error updating user {}, response code: {} response text: {}".format(username,
r.status_code,
r.text))

View file

@ -1,51 +0,0 @@
# Import users from other stores
`convertusers` is a very simple command line client, written in python, to import users from other stores. It requires `python3` or `python2`.
Here is the usage:
```console
usage: convertusers [-h] [--min-uid MIN_UID] [--max-uid MAX_UID] [--usernames USERNAMES [USERNAMES ...]]
[--force-uid FORCE_UID] [--force-gid FORCE_GID]
input_file {unix-passwd,pure-ftpd,proftpd} output_file
Convert users to a JSON format suitable to use with loadddata
positional arguments:
input_file
{unix-passwd,pure-ftpd,proftpd}
To import from unix-passwd format you need the permission to read /etc/shadow that is typically
granted to the root user only
output_file
optional arguments:
-h, --help show this help message and exit
--min-uid MIN_UID if >= 0 only import users with UID greater or equal to this value. Default: -1
--max-uid MAX_UID if >= 0 only import users with UID lesser or equal to this value. Default: -1
--usernames USERNAMES [USERNAMES ...]
Only import users with these usernames. Default: []
--force-uid FORCE_UID
if >= 0 the imported users will have this UID in SFTPGo. Default: -1
--force-gid FORCE_GID
if >= 0 the imported users will have this GID in SFTPGo. Default: -1
```
Let's see some examples:
```console
python convertusers "" unix-passwd unix_users.json --min-uid 500 --force-uid 1000 --force-gid 1000
```
```console
python convertusers pureftpd.passwd pure-ftpd pure_users.json --usernames "user1" "user2"
```
```console
python convertusers proftpd.passwd proftpd pro_users.json
```
The generated json file can be used as input for the `loaddata` REST API.
Please note that when importing Linux/Unix users the input file is not required: `/etc/passwd` and `/etc/shadow` are automatically parsed. `/etc/shadow` read permission is typically granted to the `root` user only, so you need to execute `convertusers` as `root`.
:warning: SFTPGo does not currently support `yescrypt` hashed passwords.

View file

@ -1,208 +0,0 @@
#!/usr/bin/env python
import argparse
import json
import sys
import time
try:
import pwd
import spwd
except ImportError:
pwd = None
class ConvertUsers:
def __init__(self, input_file, users_format, output_file, min_uid, max_uid, usernames, force_uid, force_gid):
self.input_file = input_file
self.users_format = users_format
self.output_file = output_file
self.min_uid = min_uid
self.max_uid = max_uid
self.usernames = usernames
self.force_uid = force_uid
self.force_gid = force_gid
self.SFTPGoUsers = []
def buildUserObject(self, username, password, home_dir, uid, gid, max_sessions, quota_size, quota_files, upload_bandwidth,
download_bandwidth, status, expiration_date, allowed_ip=[], denied_ip=[]):
return {'id':0, 'username':username, 'password':password, 'home_dir':home_dir, 'uid':uid, 'gid':gid,
'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files, 'permissions':{'/':["*"]},
'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
'status':status, 'expiration_date':expiration_date,
'filters':{'allowed_ip':allowed_ip, 'denied_ip':denied_ip}}
def addUser(self, user):
user['id'] = len(self.SFTPGoUsers) + 1
print('')
print('New user imported: {}'.format(user))
print('')
self.SFTPGoUsers.append(user)
def saveUsers(self):
if self.SFTPGoUsers:
data = {'users':self.SFTPGoUsers}
jsonData = json.dumps(data)
with open(self.output_file, 'w') as f:
f.write(jsonData)
print()
print('Number of users saved to "{}": {}. You can import them using loaddata'.format(self.output_file,
len(self.SFTPGoUsers)))
print()
sys.exit(0)
else:
print('No user imported')
sys.exit(1)
def convert(self):
if self.users_format == 'unix-passwd':
self.convertFromUnixPasswd()
elif self.users_format == 'pure-ftpd':
self.convertFromPureFTPD()
else:
self.convertFromProFTPD()
self.saveUsers()
def isUserValid(self, username, uid):
if self.usernames and not username in self.usernames:
return False
if self.min_uid >= 0 and uid < self.min_uid:
return False
if self.max_uid >= 0 and uid > self.max_uid:
return False
return True
def convertFromUnixPasswd(self):
days_from_epoch_time = time.time() / 86400
for user in pwd.getpwall():
username = user.pw_name
password = user.pw_passwd
uid = user.pw_uid
gid = user.pw_gid
home_dir = user.pw_dir
status = 1
expiration_date = 0
if not self.isUserValid(username, uid):
continue
if self.force_uid >= 0:
uid = self.force_uid
if self.force_gid >= 0:
gid = self.force_gid
# FIXME: if the passwords aren't in /etc/shadow they are probably DES encrypted and we don't support them
if password == 'x' or password == '*':
user_info = spwd.getspnam(username)
password = user_info.sp_pwdp
if not password or password == '!!' or password == '!*':
print('cannot import user "{}" without a password'.format(username))
continue
if user_info.sp_inact > 0:
last_pwd_change_diff = days_from_epoch_time - user_info.sp_lstchg
if last_pwd_change_diff > user_info.sp_inact:
status = 0
if user_info.sp_expire > 0:
expiration_date = user_info.sp_expire * 86400
self.addUser(self.buildUserObject(username, password, home_dir, uid, gid, 0, 0, 0, 0, 0, status,
expiration_date))
def convertFromProFTPD(self):
with open(self.input_file, 'r') as f:
for line in f:
fields = line.split(':')
if len(fields) > 6:
username = fields[0]
password = fields[1]
uid = int(fields[2])
gid = int(fields[3])
home_dir = fields[5]
if not self.isUserValid(username, uid):
continue
if self.force_uid >= 0:
uid = self.force_uid
if self.force_gid >= 0:
gid = self.force_gid
self.addUser(self.buildUserObject(username, password, home_dir, uid, gid, 0, 0, 0, 0, 0, 1, 0))
def convertPureFTPDIP(self, fields):
result = []
if not fields:
return result
for v in fields.split(','):
ip_mask = v.strip()
if not ip_mask:
continue
if ip_mask.count('.') < 3 and ip_mask.count(':') < 3:
print('cannot import pure-ftpd IP: {}'.format(ip_mask))
continue
if '/' not in ip_mask:
ip_mask += '/32'
result.append(ip_mask)
return result
def convertFromPureFTPD(self):
with open(self.input_file, 'r') as f:
for line in f:
fields = line.split(':')
if len(fields) > 16:
username = fields[0]
password = fields[1]
uid = int(fields[2])
gid = int(fields[3])
home_dir = fields[5]
upload_bandwidth = 0
if fields[6]:
upload_bandwidth = int(int(fields[6]) / 1024)
download_bandwidth = 0
if fields[7]:
download_bandwidth = int(int(fields[7]) / 1024)
max_sessions = 0
if fields[10]:
max_sessions = int(fields[10])
quota_files = 0
if fields[11]:
quota_files = int(fields[11])
quota_size = 0
if fields[12]:
quota_size = int(fields[12])
allowed_ip = self.convertPureFTPDIP(fields[15])
denied_ip = self.convertPureFTPDIP(fields[16])
if not self.isUserValid(username, uid):
continue
if self.force_uid >= 0:
uid = self.force_uid
if self.force_gid >= 0:
gid = self.force_gid
self.addUser(self.buildUserObject(username, password, home_dir, uid, gid, max_sessions, quota_size,
quota_files, upload_bandwidth, download_bandwidth, 1, 0, allowed_ip,
denied_ip))
if __name__ == '__main__':
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, description=
'Convert users to a JSON format suitable to use with loadddata')
supportedUsersFormats = []
help_text = ''
if pwd is not None:
supportedUsersFormats.append('unix-passwd')
help_text = 'To import from unix-passwd format you need the permission to read /etc/shadow that is typically granted to the root user only'
supportedUsersFormats.append('pure-ftpd')
supportedUsersFormats.append('proftpd')
parser.add_argument('input_file', type=str)
parser.add_argument('users_format', type=str, choices=supportedUsersFormats, help=help_text)
parser.add_argument('output_file', type=str)
parser.add_argument('--min-uid', type=int, default=-1, help='if >= 0 only import users with UID greater or equal ' +
'to this value. Default: %(default)s')
parser.add_argument('--max-uid', type=int, default=-1, help='if >= 0 only import users with UID lesser or equal ' +
'to this value. Default: %(default)s')
parser.add_argument('--usernames', type=str, nargs='+', default=[], help='Only import users with these usernames. ' +
'Default: %(default)s')
parser.add_argument('--force-uid', type=int, default=-1, help='if >= 0 the imported users will have this UID in ' +
'SFTPGo. Default: %(default)s')
parser.add_argument('--force-gid', type=int, default=-1, help='if >= 0 the imported users will have this GID in ' +
'SFTPGo. Default: %(default)s')
args = parser.parse_args()
convertUsers = ConvertUsers(args.input_file, args.users_format, args.output_file, args.min_uid, args.max_uid,
args.usernames, args.force_uid, args.force_gid)
convertUsers.convert()

View file

@ -1,48 +0,0 @@
# LDAPAuth
This is an example for an external authentication program. It performs authentication against an LDAP server.
It is tested against [389ds](https://directory.fedoraproject.org/) and can be used as starting point to authenticate using any LDAP server including Active Directory.
You need to change the LDAP connection parameters and the user search query to match your environment.
You can build this example using the following command:
```console
go build -ldflags "-s -w" -o ldapauth
```
This program assumes that the 389ds schema was extended to add support for public keys using the following ldif file placed in `/etc/dirsrv/schema/98openssh-ldap.ldif`:
```console
dn: cn=schema
changetype: modify
add: attributetypes
attributetypes: ( 1.3.6.1.4.1.24552.500.1.1.1.13 NAME 'sshPublicKey' DESC 'MANDATORY: OpenSSH Public key' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )
-
add: objectclasses
objectClasses: ( 1.3.6.1.4.1.24552.500.1.1.2.0 NAME 'ldapPublicKey' SUP top AUXILIARY DESC 'MANDATORY: OpenSSH LPK objectclass' MUST ( uid ) MAY ( sshPublicKey ) )
-
dn: cn=sshpublickey,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config
changetype: add
cn: sshpublickey
nsIndexType: eq
nsIndexType: pres
nsSystemIndex: false
objectClass: top
objectClass: nsIndex
dn: cn=sshpublickey_self_manage,ou=groups,dc=example,dc=com
changetype: add
objectClass: top
objectClass: groupofuniquenames
cn: sshpublickey_self_manage
description: Members of this group gain the ability to edit their own sshPublicKey field
dn: dc=example,dc=com
changetype: modify
add: aci
aci: (targetattr = "sshPublicKey") (version 3.0; acl "Allow members of sshpublickey_self_manage to edit their keys"; allow(write) (groupdn = "ldap:///cn=sshpublickey_self_manage,ou=groups,dc=example,dc=com" and userdn="ldap:///self" ); )
-
```
:warning: A plugin for LDAP/Active Directory authentication is also [available](https://github.com/sftpgo/sftpgo-plugin-auth).

View file

@ -1,15 +0,0 @@
module github.com/drakkan/ldapauth
go 1.25.0
require (
github.com/go-ldap/ldap/v3 v3.4.12
golang.org/x/crypto v0.45.0
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/sys v0.38.0 // indirect
)

View file

@ -1,40 +0,0 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,175 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"log/syslog"
"os"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3"
"golang.org/x/crypto/ssh"
)
const (
rootDN = "dc=example,dc=com"
bindUsername = "cn=sftpgo," + rootDN
bindURL = "ldap:///" // That is, the server on the default port of localhost.
passwordFile = "/etc/sftpgo/admin-password.txt" // make this file readable only by the server
publicDir = "/var/www/webdav/public"
)
type userFilters struct {
DeniedLoginMethods []string `json:"denied_login_methods,omitempty"`
}
type minimalSFTPGoUser struct {
Status int `json:"status,omitempty"`
Username string `json:"username"`
HomeDir string `json:"home_dir,omitempty"`
UID int `json:"uid,omitempty"`
GID int `json:"gid,omitempty"`
Permissions map[string][]string `json:"permissions"`
Filters userFilters `json:"filters"`
}
func exitError() {
log.Printf("exitError\n")
u := minimalSFTPGoUser{
Username: "",
}
resp, _ := json.Marshal(u)
fmt.Printf("%v\n", string(resp))
os.Exit(1)
}
func printSuccessResponse(username, homeDir string, uid, gid int, permissions []string) {
u := minimalSFTPGoUser{
Username: username,
HomeDir: homeDir,
UID: uid,
GID: gid,
Status: 1,
}
u.Permissions = make(map[string][]string)
u.Permissions["/"] = permissions
// uncomment the next line to require publickey+password authentication
//u.Filters.DeniedLoginMethods = []string{"publickey", "password", "keyboard-interactive", "publickey+keyboard-interactive"}
resp, _ := json.Marshal(u)
log.Printf("%v\n", string(resp))
fmt.Printf("%v\n", string(resp))
os.Exit(0)
}
func main() {
logWriter, err := syslog.New(syslog.LOG_NOTICE, "sftpgo")
if err == nil {
log.SetOutput(logWriter)
}
// get credentials from env vars
username := os.Getenv("SFTPGO_AUTHD_USERNAME")
password := os.Getenv("SFTPGO_AUTHD_PASSWORD")
publickey := os.Getenv("SFTPGO_AUTHD_PUBLIC_KEY")
if strings.ToLower(username) == "anonymous" {
printSuccessResponse("anonymous", publicDir, 0, 0, []string{"list", "download"})
return
}
l, err := ldap.DialURL(bindURL)
if err != nil {
log.Printf("DialURL: %s\n", err.Error())
exitError()
}
defer l.Close()
// bind to the ldap server with an account that can read users
bindPassword, err := os.ReadFile(passwordFile)
if err != nil {
log.Printf("ReadFile(%s): %s\n", passwordFile, err.Error())
exitError()
}
err = l.Bind(bindUsername, string(bindPassword))
if err != nil {
log.Printf("Bind(%s): %s\n", bindUsername, err.Error())
exitError()
}
// search the user trying to login and fetch some attributes, this search string is tested against 389ds using the default configuration
log.Printf("username=%s\n", username)
searchFilter := fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username))
searchRequest := ldap.NewSearchRequest(
"ou=people," + rootDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchFilter,
[]string{"dn", "uid", "homeDirectory", "uidNumber", "gidNumber", "nsSshPublicKey"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
log.Printf("Search(%s): %s\n", searchFilter, err.Error())
exitError()
}
// we expect exactly one user
if len(sr.Entries) != 1 {
log.Printf("Search(%s): %d entries\n", searchFilter, len(sr.Entries))
exitError()
}
if len(publickey) > 0 {
// check public key
userKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publickey))
if err != nil {
log.Printf("ParseAuthorizedKey(%s): %s\n", publickey, err.Error())
exitError()
}
authOk := false
for _, k := range sr.Entries[0].GetAttributeValues("nsSshPublicKey") {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
// we skip an invalid public key stored inside the LDAP server
if err != nil {
continue
}
if bytes.Equal(key.Marshal(), userKey.Marshal()) {
authOk = true
break
}
}
if !authOk {
log.Printf("publickey %s !authOk\n", publickey)
exitError()
}
} else {
// bind to the LDAP server with the user dn and the given password to check the password
userdn := sr.Entries[0].DN
// log.Printf("password=%s\n", password)
err = l.Bind(userdn, password)
if err != nil {
log.Printf("Bind(%s): %s\n", userdn, err.Error())
exitError()
}
}
// People in the LDAP directory aren't necessarily Linux users;
// so they might not have a uidNumber or gidNumber.
uidNumber := sr.Entries[0].GetAttributeValue("uidNumber")
uid, err := strconv.Atoi(uidNumber)
if err != nil {
//log.Printf("uid Atoi(%s) = %s\n", uidNumber, err.Error())
uid = 0
}
gidNumber := sr.Entries[0].GetAttributeValue("gidNumber")
gid, err := strconv.Atoi(gidNumber)
if err != nil {
//log.Printf("gid Atoi(%s) = %s\n", gidNumber, err.Error())
gid = 0
}
homeDir := sr.Entries[0].GetAttributeValue("homeDirectory")
if (len(homeDir) <= 0) {
homeDir = publicDir // homeDir is a required attribute.
}
// return the authenticated user
printSuccessResponse(sr.Entries[0].GetAttributeValue("uid"), homeDir, uid, gid, []string{"*"})
}

View file

@ -1,13 +0,0 @@
# LDAPAuthServer
This is an example for an HTTP server to use as external authentication HTTP hook. It performs authentication against an LDAP server.
It is tested against [389ds](https://directory.fedoraproject.org/) and can be used as starting point to authenticate using any LDAP server including Active Directory.
You can configure the server using the [ldapauth.toml](./ldapauth.toml) configuration file.
You can build this example using the following command:
```console
go build -ldflags "-s -w" -o ldapauthserver
```
:warning: A plugin for LDAP/Active Directory authentication is also [available](https://github.com/sftpgo/sftpgo-plugin-auth).

View file

@ -1,158 +0,0 @@
package cmd
import (
"fmt"
"os"
"github.com/drakkan/sftpgo/ldapauthserver/config"
"github.com/drakkan/sftpgo/ldapauthserver/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const (
logSender = "cmd"
configDirFlag = "config-dir"
configDirKey = "config_dir"
configFileFlag = "config-file"
configFileKey = "config_file"
logFilePathFlag = "log-file-path"
logFilePathKey = "log_file_path"
logMaxSizeFlag = "log-max-size"
logMaxSizeKey = "log_max_size"
logMaxBackupFlag = "log-max-backups"
logMaxBackupKey = "log_max_backups"
logMaxAgeFlag = "log-max-age"
logMaxAgeKey = "log_max_age"
logCompressFlag = "log-compress"
logCompressKey = "log_compress"
logVerboseFlag = "log-verbose"
logVerboseKey = "log_verbose"
profilerFlag = "profiler"
profilerKey = "profiler"
defaultConfigDir = "."
defaultConfigName = config.DefaultConfigName
defaultLogFile = "ldapauth.log"
defaultLogMaxSize = 10
defaultLogMaxBackup = 5
defaultLogMaxAge = 28
defaultLogCompress = false
defaultLogVerbose = true
)
var (
configDir string
configFile string
logFilePath string
logMaxSize int
logMaxBackups int
logMaxAge int
logCompress bool
logVerbose bool
rootCmd = &cobra.Command{
Use: "ldapauthserver",
Short: "LDAP Authentication Server for SFTPGo",
}
)
func init() {
version := utils.GetAppVersion()
rootCmd.Flags().BoolP("version", "v", false, "")
rootCmd.Version = version.GetVersionAsString()
rootCmd.SetVersionTemplate(`{{printf "LDAP Authentication Server version: "}}{{printf "%s" .Version}}
`)
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func addConfigFlags(cmd *cobra.Command) {
viper.SetDefault(configDirKey, defaultConfigDir)
viper.BindEnv(configDirKey, "LDAPAUTH_CONFIG_DIR")
cmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey),
`Location for the config dir. This directory
should contain the "ldapauth" configuration
file or the configured config-file. This flag
can be set using LDAPAUTH_CONFIG_DIR env var too.
`)
viper.BindPFlag(configDirKey, cmd.Flags().Lookup(configDirFlag))
viper.SetDefault(configFileKey, defaultConfigName)
viper.BindEnv(configFileKey, "LDAPAUTH_CONFIG_FILE")
cmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey),
`Name for the configuration file. It must be
the name of a file stored in config-dir not
the absolute path to the configuration file.
The specified file name must have no extension
we automatically load JSON, YAML, TOML, HCL and
Java properties. Therefore if you set \"ldapauth\"
then \"ldapauth.toml\", \"ldapauth.yaml\" and
so on are searched. This flag can be set using
LDAPAUTH_CONFIG_FILE env var too.
`)
viper.BindPFlag(configFileKey, cmd.Flags().Lookup(configFileFlag))
}
func addServeFlags(cmd *cobra.Command) {
addConfigFlags(cmd)
viper.SetDefault(logFilePathKey, defaultLogFile)
viper.BindEnv(logFilePathKey, "LDAPAUTH_LOG_FILE_PATH")
cmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey),
`Location for the log file. Leave empty to write
logs to the standard output. This flag can be
set using LDAPAUTH_LOG_FILE_PATH env var too.
`)
viper.BindPFlag(logFilePathKey, cmd.Flags().Lookup(logFilePathFlag))
viper.SetDefault(logMaxSizeKey, defaultLogMaxSize)
viper.BindEnv(logMaxSizeKey, "LDAPAUTH_LOG_MAX_SIZE")
cmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey),
`Maximum size in megabytes of the log file
before it gets rotated. This flag can be set
using LDAPAUTH_LOG_MAX_SIZE env var too. It
is unused if log-file-path is empty.`)
viper.BindPFlag(logMaxSizeKey, cmd.Flags().Lookup(logMaxSizeFlag))
viper.SetDefault(logMaxBackupKey, defaultLogMaxBackup)
viper.BindEnv(logMaxBackupKey, "LDAPAUTH_LOG_MAX_BACKUPS")
cmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey),
`Maximum number of old log files to retain.
This flag can be set using LDAPAUTH_LOG_MAX_BACKUPS
env var too. It is unused if log-file-path is
empty.`)
viper.BindPFlag(logMaxBackupKey, cmd.Flags().Lookup(logMaxBackupFlag))
viper.SetDefault(logMaxAgeKey, defaultLogMaxAge)
viper.BindEnv(logMaxAgeKey, "LDAPAUTH_LOG_MAX_AGE")
cmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey),
`Maximum number of days to retain old log files.
This flag can be set using LDAPAUTH_LOG_MAX_AGE
env var too. It is unused if log-file-path is
empty.`)
viper.BindPFlag(logMaxAgeKey, cmd.Flags().Lookup(logMaxAgeFlag))
viper.SetDefault(logCompressKey, defaultLogCompress)
viper.BindEnv(logCompressKey, "LDAPAUTH_LOG_COMPRESS")
cmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey),
`Determine if the rotated log files
should be compressed using gzip. This flag can
be set using LDAPAUTH_LOG_COMPRESS env var too.
It is unused if log-file-path is empty.`)
viper.BindPFlag(logCompressKey, cmd.Flags().Lookup(logCompressFlag))
viper.SetDefault(logVerboseKey, defaultLogVerbose)
viper.BindEnv(logVerboseKey, "LDAPAUTH_LOG_VERBOSE")
cmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey),
`Enable verbose logs. This flag can be set
using LDAPAUTH_LOG_VERBOSE env var too.
`)
viper.BindPFlag(logVerboseKey, cmd.Flags().Lookup(logVerboseFlag))
}

View file

@ -1,49 +0,0 @@
package cmd
import (
"path/filepath"
"github.com/drakkan/sftpgo/ldapauthserver/config"
"github.com/drakkan/sftpgo/ldapauthserver/httpd"
"github.com/drakkan/sftpgo/ldapauthserver/logger"
"github.com/drakkan/sftpgo/ldapauthserver/utils"
"github.com/rs/zerolog"
"github.com/spf13/cobra"
)
var (
serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the LDAP Authentication Server",
Long: `To start the server with the default values for the command line flags simply use:
ldapauthserver serve
Please take a look at the usage below to customize the startup options`,
Run: func(cmd *cobra.Command, args []string) {
startServer()
},
}
)
func init() {
rootCmd.AddCommand(serveCmd)
addServeFlags(serveCmd)
}
func startServer() error {
logLevel := zerolog.DebugLevel
if !logVerbose {
logLevel = zerolog.InfoLevel
}
if !filepath.IsAbs(logFilePath) && utils.IsFileInputValid(logFilePath) {
logFilePath = filepath.Join(configDir, logFilePath)
}
logger.InitLogger(logFilePath, logMaxSize, logMaxBackups, logMaxAge, logCompress, logLevel)
version := utils.GetAppVersion()
logger.Info(logSender, "", "starting LDAP Auth Server %v, config dir: %v, config file: %v, log max size: %v log max backups: %v "+
"log max age: %v log verbose: %v, log compress: %v", version.GetVersionAsString(), configDir, configFile, logMaxSize,
logMaxBackups, logMaxAge, logVerbose, logCompress)
config.LoadConfig(configDir, configFile)
return httpd.StartHTTPServer(configDir, config.GetHTTPDConfig())
}

View file

@ -1,158 +0,0 @@
package config
import (
"strings"
"github.com/drakkan/sftpgo/ldapauthserver/logger"
"github.com/spf13/viper"
)
const (
logSender = "config"
// DefaultConfigName defines the name for the default config file.
// This is the file name without extension, we use viper and so we
// support all the config files format supported by viper
DefaultConfigName = "ldapauth"
// ConfigEnvPrefix defines a prefix that ENVIRONMENT variables will use
configEnvPrefix = "ldapauth"
)
// HTTPDConfig defines configuration for the HTTPD server
type HTTPDConfig struct {
BindAddress string `mapstructure:"bind_address"`
BindPort int `mapstructure:"bind_port"`
AuthUserFile string `mapstructure:"auth_user_file"`
CertificateFile string `mapstructure:"certificate_file"`
CertificateKeyFile string `mapstructure:"certificate_key_file"`
}
// LDAPConfig defines the configuration parameters for LDAP connections and searches
type LDAPConfig struct {
BaseDN string `mapstructure:"basedn"`
BindURL string `mapstructure:"bind_url"`
BindUsername string `mapstructure:"bind_username"`
BindPassword string `mapstructure:"bind_password"`
SearchFilter string `mapstructure:"search_filter"`
SearchBaseAttrs []string `mapstructure:"search_base_attrs"`
DefaultUID int `mapstructure:"default_uid"`
DefaultGID int `mapstructure:"default_gid"`
ForceDefaultUID bool `mapstructure:"force_default_uid"`
ForceDefaultGID bool `mapstructure:"force_default_gid"`
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
CACertificates []string `mapstructure:"ca_certificates"`
}
type appConfig struct {
HTTPD HTTPDConfig `mapstructure:"httpd"`
LDAP LDAPConfig `mapstructure:"ldap"`
}
var conf appConfig
func init() {
conf = appConfig{
HTTPD: HTTPDConfig{
BindAddress: "",
BindPort: 9000,
AuthUserFile: "",
CertificateFile: "",
CertificateKeyFile: "",
},
LDAP: LDAPConfig{
BaseDN: "dc=example,dc=com",
BindURL: "ldap://192.168.1.103:389",
BindUsername: "cn=Directory Manager",
BindPassword: "YOUR_ADMIN_PASSWORD_HERE",
SearchFilter: "(&(objectClass=nsPerson)(uid=%s))",
SearchBaseAttrs: []string{
"dn",
"homeDirectory",
"uidNumber",
"gidNumber",
"nsSshPublicKey",
},
DefaultUID: 0,
DefaultGID: 0,
ForceDefaultUID: true,
ForceDefaultGID: true,
InsecureSkipVerify: false,
CACertificates: nil,
},
}
viper.SetEnvPrefix(configEnvPrefix)
replacer := strings.NewReplacer(".", "__")
viper.SetEnvKeyReplacer(replacer)
viper.SetConfigName(DefaultConfigName)
viper.AutomaticEnv()
viper.AllowEmptyEnv(true)
}
// GetHomeDirectory returns the configured name for the LDAP field to use as home directory
func (l *LDAPConfig) GetHomeDirectory() string {
if len(l.SearchBaseAttrs) > 1 {
return l.SearchBaseAttrs[1]
}
return "homeDirectory"
}
// GetUIDNumber returns the configured name for the LDAP field to use as UID
func (l *LDAPConfig) GetUIDNumber() string {
if len(l.SearchBaseAttrs) > 2 {
return l.SearchBaseAttrs[2]
}
return "uidNumber"
}
// GetGIDNumber returns the configured name for the LDAP field to use as GID
func (l *LDAPConfig) GetGIDNumber() string {
if len(l.SearchBaseAttrs) > 3 {
return l.SearchBaseAttrs[3]
}
return "gidNumber"
}
// GetPublicKey returns the configured name for the LDAP field to use as public keys
func (l *LDAPConfig) GetPublicKey() string {
if len(l.SearchBaseAttrs) > 4 {
return l.SearchBaseAttrs[4]
}
return "nsSshPublicKey"
}
// GetHTTPDConfig returns the configuration for the HTTP server
func GetHTTPDConfig() HTTPDConfig {
return conf.HTTPD
}
// GetLDAPConfig returns LDAP related settings
func GetLDAPConfig() LDAPConfig {
return conf.LDAP
}
func getRedactedConf() appConfig {
c := conf
return c
}
// LoadConfig loads the configuration
func LoadConfig(configDir, configName string) error {
var err error
viper.AddConfigPath(configDir)
viper.AddConfigPath(".")
viper.SetConfigName(configName)
if err = viper.ReadInConfig(); err != nil {
logger.Warn(logSender, "", "error loading configuration file: %v. Default configuration will be used: %+v",
err, getRedactedConf())
logger.WarnToConsole("error loading configuration file: %v. Default configuration will be used.", err)
return err
}
err = viper.Unmarshal(&conf)
if err != nil {
logger.Warn(logSender, "", "error parsing configuration file: %v. Default configuration will be used: %+v",
err, getRedactedConf())
logger.WarnToConsole("error parsing configuration file: %v. Default configuration will be used.", err)
return err
}
logger.Debug(logSender, "", "config file used: '%q', config loaded: %+v", viper.ConfigFileUsed(), getRedactedConf())
return err
}

View file

@ -1,37 +0,0 @@
module github.com/drakkan/sftpgo/ldapauthserver
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/render v1.0.3
github.com/go-ldap/ldap/v3 v3.4.12
github.com/nathanaelle/password/v2 v2.0.1
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.45.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

View file

@ -1,114 +0,0 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nathanaelle/password/v2 v2.0.1 h1:ItoCTdsuIWzilYmllQPa3DR3YoCXcpfxScWLqr8Ii2s=
github.com/nathanaelle/password/v2 v2.0.1/go.mod h1:eaoT+ICQEPNtikBRIAatN8ThWwMhVG+r1jTw60BvPJk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200311171314-f7b00557c8c4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,149 +0,0 @@
package httpd
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/drakkan/sftpgo/ldapauthserver/config"
"github.com/drakkan/sftpgo/ldapauthserver/logger"
"github.com/drakkan/sftpgo/ldapauthserver/utils"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
)
const (
logSender = "httpd"
versionPath = "/api/v1/version"
checkAuthPath = "/api/v1/check_auth"
maxRequestSize = 1 << 18 // 256KB
)
var (
ldapConfig config.LDAPConfig
httpAuth httpAuthProvider
certMgr *certManager
rootCAs *x509.CertPool
)
// StartHTTPServer initializes and starts the HTTP Server
func StartHTTPServer(configDir string, httpConfig config.HTTPDConfig) error {
var err error
authUserFile := getConfigPath(httpConfig.AuthUserFile, configDir)
httpAuth, err = newBasicAuthProvider(authUserFile)
if err != nil {
return err
}
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(logger.NewStructuredLogger(logger.GetLogger()))
router.Use(middleware.Recoverer)
router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, nil, "Not Found", http.StatusNotFound)
}))
router.MethodNotAllowed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, nil, "Method not allowed", http.StatusMethodNotAllowed)
}))
router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, utils.GetAppVersion())
})
router.Group(func(router chi.Router) {
router.Use(checkAuth)
router.Post(checkAuthPath, checkSFTPGoUserAuth)
})
ldapConfig = config.GetLDAPConfig()
loadCACerts(configDir)
certificateFile := getConfigPath(httpConfig.CertificateFile, configDir)
certificateKeyFile := getConfigPath(httpConfig.CertificateKeyFile, configDir)
httpServer := &http.Server{
Addr: fmt.Sprintf("%s:%d", httpConfig.BindAddress, httpConfig.BindPort),
Handler: router,
ReadTimeout: 70 * time.Second,
WriteTimeout: 70 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16, // 64KB
}
if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
certMgr, err = newCertManager(certificateFile, certificateKeyFile)
if err != nil {
return err
}
config := &tls.Config{
GetCertificate: certMgr.GetCertificateFunc(),
MinVersion: tls.VersionTLS12,
}
httpServer.TLSConfig = config
return httpServer.ListenAndServeTLS("", "")
}
return httpServer.ListenAndServe()
}
func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
var errorString string
if err != nil {
errorString = err.Error()
}
resp := apiResponse{
Error: errorString,
Message: message,
HTTPStatus: code,
}
ctx := context.WithValue(r.Context(), render.StatusCtxKey, code)
render.JSON(w, r.WithContext(ctx), resp)
}
func loadCACerts(configDir string) error {
var err error
rootCAs, err = x509.SystemCertPool()
if err != nil {
rootCAs = x509.NewCertPool()
}
for _, ca := range ldapConfig.CACertificates {
caPath := getConfigPath(ca, configDir)
certs, err := os.ReadFile(caPath)
if err != nil {
logger.Warn(logSender, "", "error loading ca cert %q: %v", caPath, err)
return err
}
if !rootCAs.AppendCertsFromPEM(certs) {
logger.Warn(logSender, "", "unable to add ca cert %q", caPath)
} else {
logger.Debug(logSender, "", "ca cert %q added to the trusted certificates", caPath)
}
}
return nil
}
// ReloadTLSCertificate reloads the TLS certificate and key from the configured paths
func ReloadTLSCertificate() {
if certMgr != nil {
certMgr.loadCertificate()
}
}
func getConfigPath(name, configDir string) string {
if !utils.IsFileInputValid(name) {
return ""
}
if len(name) > 0 && !filepath.IsAbs(name) {
return filepath.Join(configDir, name)
}
return name
}

Some files were not shown because too many files have changed in this diff Show more