FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// Package sftpserver implements an embedded, per-tenant SFTP server for
|
||||
// archivdms. Instead of provisioning real OS users + OpenSSH
|
||||
// ChrootDirectory per tenant, the server runs inside the archivdms binary
|
||||
// and enforces tenant isolation entirely in software:
|
||||
//
|
||||
// - Authentication is checked against the `sftp_credentials` table
|
||||
// (internal/storage/sftp_credentials.go), a narrow, independently
|
||||
// revocable credential — not a full user login.
|
||||
// - Once authenticated, a tenant is virtually "locked" into
|
||||
// `<storage.base_path>/inbox/<tenant_id>/`: the SFTP handlers only ever
|
||||
// resolve paths relative to that directory and reject any path that
|
||||
// would escape it (no OS-level chroot, no setuid, no real filesystem
|
||||
// jail — just careful path handling).
|
||||
// - A polling watcher goroutine picks up files dropped into that
|
||||
// directory and feeds them through the exact same
|
||||
// inbox->hash->store->OCR->DB pipeline as the HTTP upload endpoint
|
||||
// (see internal/api/document_handlers.go storeUploadedFile, exposed
|
||||
// here via the UploadFunc callback to avoid an import cycle between
|
||||
// internal/api and internal/sftpserver).
|
||||
package sftpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"archivdms/config"
|
||||
"archivdms/internal/audit"
|
||||
"archivdms/internal/storage"
|
||||
)
|
||||
|
||||
// pollInterval is how often the watcher scans inbox directories for new
|
||||
// files dropped over SFTP. No fsnotify dependency — a simple polling loop is
|
||||
// good enough for this volume/latency profile (analogous to the project's
|
||||
// existing cron-style background jobs).
|
||||
const pollInterval = 5 * time.Second
|
||||
|
||||
// UploadFunc is the shared upload-pipeline entry point, implemented by
|
||||
// internal/api.Server.StoreUploadedFile. Kept as a function value (rather
|
||||
// than importing internal/api directly) to avoid an import cycle:
|
||||
// internal/api already imports internal/storage and internal/audit, and
|
||||
// wiring happens the other way around in cmd/archivdms/main.go.
|
||||
type UploadFunc func(ctx context.Context, tenantID int64, title, docType, correspondent string, file io.Reader, filename, contentType string) (*storage.Document, string, error)
|
||||
|
||||
// Server is the embedded per-tenant SFTP server.
|
||||
type Server struct {
|
||||
cfg config.SFTPConfig
|
||||
storageCfg config.StorageConfig
|
||||
store *storage.Store
|
||||
audlog *audit.Logger
|
||||
logger *slog.Logger
|
||||
upload UploadFunc
|
||||
|
||||
listener net.Listener
|
||||
sshCfg *ssh.ServerConfig
|
||||
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// New constructs an SFTP server. Call Start to begin listening and Stop to
|
||||
// shut down.
|
||||
func New(cfg config.SFTPConfig, storageCfg config.StorageConfig, store *storage.Store, audlog *audit.Logger, logger *slog.Logger, upload UploadFunc) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
storageCfg: storageCfg,
|
||||
store: store,
|
||||
audlog: audlog,
|
||||
logger: logger,
|
||||
upload: upload,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads/generates the host key, opens the listener, and launches the
|
||||
// accept loop plus the inbox watcher as background goroutines. It returns
|
||||
// once the listener is up (or an error occurred setting it up); the accept
|
||||
// loop itself keeps running in the background.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
signer, err := s.loadOrCreateHostKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpserver: host key: %w", err)
|
||||
}
|
||||
|
||||
s.sshCfg = &ssh.ServerConfig{
|
||||
PasswordCallback: s.passwordCallback,
|
||||
}
|
||||
s.sshCfg.AddHostKey(signer)
|
||||
|
||||
bind := s.cfg.ResolvedBind()
|
||||
ln, err := net.Listen("tcp", bind)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpserver: listen %s: %w", bind, err)
|
||||
}
|
||||
s.listener = ln
|
||||
s.logger.Info("sftp server listening", "addr", bind)
|
||||
|
||||
go s.acceptLoop()
|
||||
go s.watchLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the listener, ending the accept loop, and signals the watcher
|
||||
// to exit.
|
||||
func (s *Server) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- host key bootstrap ---
|
||||
|
||||
func (s *Server) loadOrCreateHostKey() (ssh.Signer, error) {
|
||||
path := s.cfg.ResolvedHostKeyPath(s.storageCfg.BasePath)
|
||||
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return ssh.ParsePrivateKey(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("sftp host key not found, generating a new one", "path", path)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(key)
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create host key dir: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write host key: %w", err)
|
||||
}
|
||||
return ssh.NewSignerFromKey(key)
|
||||
}
|
||||
|
||||
// --- authentication ---
|
||||
|
||||
func (s *Server) passwordCallback(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
username := meta.User()
|
||||
ctx := context.Background()
|
||||
cred, err := s.store.VerifySFTPLogin(ctx, username, string(password))
|
||||
success := err == nil
|
||||
|
||||
detail := ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
}
|
||||
var tenantID *int64
|
||||
if cred != nil {
|
||||
tenantID = &cred.TenantID
|
||||
}
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSFTPLogin,
|
||||
Username: username,
|
||||
IPAddress: remoteIPFromConn(meta.RemoteAddr()),
|
||||
TenantID: tenantID,
|
||||
Success: success,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
if !success {
|
||||
return nil, fmt.Errorf("sftpserver: authentication failed")
|
||||
}
|
||||
|
||||
_ = s.store.TouchSFTPLastLogin(ctx, cred.ID)
|
||||
|
||||
return &ssh.Permissions{
|
||||
Extensions: map[string]string{
|
||||
"tenant_id": strconv.FormatInt(cred.TenantID, 10),
|
||||
"username": username,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteIPFromConn(addr net.Addr) string {
|
||||
if addr == nil {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return addr.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// --- accept loop ---
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
s.logger.Warn("sftp accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp handshake failed", "err", err, "remote", conn.RemoteAddr())
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
tenantIDStr := sshConn.Permissions.Extensions["tenant_id"]
|
||||
tenantID, err := strconv.ParseInt(tenantIDStr, 10, 64)
|
||||
if err != nil {
|
||||
s.logger.Error("sftp connection missing tenant_id extension", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp channel accept failed", "err", err)
|
||||
continue
|
||||
}
|
||||
go s.handleSession(channel, requests, tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, tenantID int64) {
|
||||
defer channel.Close()
|
||||
|
||||
for req := range requests {
|
||||
ok := req.Type == "subsystem" && string(req.Payload[4:]) == "sftp"
|
||||
if req.WantReply {
|
||||
_ = req.Reply(ok, nil)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
root := filepath.Join(s.storageCfg.InboxPath(), strconv.FormatInt(tenantID, 10))
|
||||
if err := os.MkdirAll(root, 0o750); err != nil {
|
||||
s.logger.Error("sftp: create tenant inbox dir failed", "tenant_id", tenantID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
fs := &tenantFS{root: root}
|
||||
handlers := sftp.Handlers{
|
||||
FileGet: fs,
|
||||
FilePut: fs,
|
||||
FileCmd: fs,
|
||||
FileList: fs,
|
||||
}
|
||||
server := sftp.NewRequestServer(channel, handlers)
|
||||
if err := server.Serve(); err != nil && err != io.EOF {
|
||||
s.logger.Warn("sftp session ended with error", "tenant_id", tenantID, "err", err)
|
||||
}
|
||||
_ = server.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- watcher: picks up files dropped into inbox/<tenant_id>/ and feeds them
|
||||
// through the shared upload pipeline ---
|
||||
|
||||
func (s *Server) watchLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.scanInbox(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scanInbox(ctx context.Context) {
|
||||
base := s.storageCfg.InboxPath()
|
||||
tenantDirs, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.logger.Warn("sftp watcher: read inbox root failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, td := range tenantDirs {
|
||||
if !td.IsDir() {
|
||||
continue
|
||||
}
|
||||
tenantID, err := strconv.ParseInt(td.Name(), 10, 64)
|
||||
if err != nil {
|
||||
continue // not a tenant directory (e.g. stray file), skip
|
||||
}
|
||||
s.scanTenantInbox(ctx, tenantID, filepath.Join(base, td.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) scanTenantInbox(ctx context.Context, tenantID int64, dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp watcher: read tenant inbox failed", "tenant_id", tenantID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
s.processInboxFile(ctx, tenantID, path, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) processInboxFile(ctx context.Context, tenantID int64, path, filename string) {
|
||||
// Skip files still being written (e.g. an in-progress SFTP PUT). A
|
||||
// simple heuristic: if the file's mtime is very recent, give the next
|
||||
// poll cycle a chance to see it settle instead of processing a partial
|
||||
// upload.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return // vanished since ReadDir, e.g. concurrent processing
|
||||
}
|
||||
if time.Since(info.ModTime()) < pollInterval {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("sftp watcher: open inbox file failed", "path", path, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
doc, warn, err := s.upload(ctx, tenantID, title, "", "", f, filename, "")
|
||||
f.Close()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateContentHash) {
|
||||
s.logger.Info("sftp watcher: duplicate content, discarding", "path", path)
|
||||
} else {
|
||||
s.logger.Error("sftp watcher: upload pipeline failed", "path", path, "err", err)
|
||||
if s.audlog != nil {
|
||||
s.audlog.Log(audit.Entry{EventType: audit.EventDocumentCreate, Username: "sftp:tenant-" + strconv.FormatInt(tenantID, 10), TenantID: &tenantID, Success: false, Detail: err.Error()})
|
||||
}
|
||||
return // leave the file in place for a retry on the next cycle
|
||||
}
|
||||
} else {
|
||||
s.logger.Info("sftp watcher: document created", "document_id", doc.ID, "path", path)
|
||||
if warn != "" && s.logger != nil {
|
||||
s.logger.Warn("sftp watcher: upload succeeded with warning", "document_id", doc.ID, "warn", warn)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the original SFTP-dropped file: storeUploadedFile writes its own
|
||||
// copy into inbox/<tenant>/<random>.<ext> and moves *that* into store/, so
|
||||
// this original drop file is no longer needed either way (processed or
|
||||
// confirmed duplicate).
|
||||
if err := os.Remove(path); err != nil {
|
||||
s.logger.Warn("sftp watcher: cleanup of inbox file failed", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package sftpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// tenantFS implements the four github.com/pkg/sftp request-server
|
||||
// interfaces (FileReader/FileWriter/FileCmder/FileLister) on top of a single
|
||||
// real directory (root) — the authenticated tenant's
|
||||
// inbox/<tenant_id>/ folder.
|
||||
//
|
||||
// This is the "virtual chroot": every incoming SFTP path is resolved
|
||||
// relative to root and validated to never escape it (no "..", no absolute
|
||||
// paths pointing elsewhere). v1 intentionally supports only a flat
|
||||
// directory — no subfolder create/navigate/delete — which keeps the path
|
||||
// validation trivial: a request path may only name a direct child of root.
|
||||
type tenantFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// resolve maps a virtual SFTP path ("/", "/foo.pdf", ...) onto a real path
|
||||
// under fs.root, rejecting anything that isn't a direct child of the root
|
||||
// (blocks path traversal and subfolder use in one check).
|
||||
func (fs *tenantFS) resolve(virtual string) (string, error) {
|
||||
clean := filepath.Clean("/" + virtual)
|
||||
if clean == "/" {
|
||||
return fs.root, nil
|
||||
}
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
if strings.Contains(clean, "/") || clean == ".." || clean == "." {
|
||||
return "", errors.New("sftpserver: path escapes tenant root or is not a direct child")
|
||||
}
|
||||
return filepath.Join(fs.root, clean), nil
|
||||
}
|
||||
|
||||
// Fileread implements sftp.FileReader (GET). Reading back an already
|
||||
// uploaded-but-not-yet-processed file is allowed (harmless), but there is
|
||||
// nothing to read once the watcher has moved the file into store/ (by
|
||||
// design — inbox/ is a transient staging area, not a browsable archive).
|
||||
func (fs *tenantFS) Fileread(r *sftp.Request) (io.ReaderAt, error) {
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Filewrite implements sftp.FileWriter (PUT). New files are created at the
|
||||
// root of the tenant's inbox only; existing files may not be overwritten
|
||||
// (O_EXCL) to avoid a client silently clobbering a file the watcher hasn't
|
||||
// picked up yet.
|
||||
func (fs *tenantFS) Filewrite(r *sftp.Request) (io.WriterAt, error) {
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Filecmd implements sftp.FileCmder for out-of-band filesystem operations
|
||||
// (Remove, Rename, Mkdir, Setstat, ...). v1 deliberately supports none of
|
||||
// these beyond what's needed for a plain "put a file" workflow — clients
|
||||
// get a clean permission error rather than silently succeeding.
|
||||
func (fs *tenantFS) Filecmd(r *sftp.Request) error {
|
||||
return errors.New("sftpserver: operation not permitted (only uploading new files is supported)")
|
||||
}
|
||||
|
||||
// Filelist implements sftp.FileLister (LIST/STAT). Listing the root shows
|
||||
// the tenant's pending (not-yet-watched) inbox files; anything else is
|
||||
// rejected by resolve.
|
||||
func (fs *tenantFS) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
|
||||
switch r.Method {
|
||||
case "List":
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]os.FileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
return listerAt(infos), nil
|
||||
case "Stat", "Lstat":
|
||||
path, err := fs.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listerAt([]os.FileInfo{info}), nil
|
||||
default:
|
||||
return nil, errors.New("sftpserver: unsupported list method " + r.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// listerAt is the minimal []os.FileInfo -> sftp.ListerAt adapter expected by
|
||||
// github.com/pkg/sftp's request server.
|
||||
type listerAt []os.FileInfo
|
||||
|
||||
func (l listerAt) ListAt(dst []os.FileInfo, offset int64) (int, error) {
|
||||
if offset >= int64(len(l)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(dst, l[offset:])
|
||||
if n < len(dst) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user