- [WIP] database migrations

Signed-off-by: HeshamTB <hishaminv@gmail.com>
This commit is contained in:
HeshamTB 2026-03-11 18:51:15 +03:00
commit 2b9029464d
Signed by: Hesham
GPG Key ID: 74876157D199B09E
11 changed files with 320 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
go.sum
cmd/sft/data.db

31
README Normal file
View File

@ -0,0 +1,31 @@
2026-03-05
Secure File Transfer
A secure file transfer in/out critical infra. SFT acts as the sole gateway for
file transfer.
High level
Given a protected IT/OT env, an SFT node allows a login using LDAP or other
auth method to whitelist or all users in an auth domain, and transfer files
No clustering for now.
Componenets:
- Main server handles
- Auth Confing
- Media trust
- File quarintne and scannning
- SFT Node trust
- User interfaces/API
- SFT Node
- Auth users
- File transfer
SFT Servers seperate a low protection env to a critical env. Transfer files
into a temporerey env for scanning.
Possible to integrate third-party scanning tools. Must be able to run
without internet, or at least scan locally while connected to internet.

1
README.md Symbolic link
View File

@ -0,0 +1 @@
README

225
cmd/sft/sft.go Normal file
View File

@ -0,0 +1,225 @@
package main
import (
"database/sql"
"io"
"log/slog"
"os"
"strconv"
"strings"
"gitea.hbanafa.com/HeshamTB/sft/migrations"
_ "github.com/mattn/go-sqlite3"
)
var rootLog slog.Logger
const EXIT_FAIL = 1
const FILE_DB_REV0 = "0_init.sql"
const DB_PATH = "./data.db"
const DB_DRIVER = "sqlite3"
/*
Entry for SFT server
- WebUI
- File Quarintine and scanning
- DB
- LDAP Auth
*/
func main() {
lopt := slog.HandlerOptions{ Level: slog.LevelDebug }
lhan := slog.NewTextHandler(os.Stdout, &lopt)
rootLog := slog.New(lhan)
rootLog.Info("init sft server")
// TODO: Init DB
// This creates the db file if it does not exist
db, err := sql.Open(DB_DRIVER, DB_PATH)
if err != nil {
rootLog.Error("could not open connection to db", "err", err.Error())
os.Exit(EXIT_FAIL)
}
defer db.Close()
db.SetMaxOpenConns(0)
// sqlStmt := `
// create table if not exists _sft_db (db_rev integer not null, sft_ver text);
// delete from _sft_db;
// `
//
// _, err = db.Exec(sqlStmt)
// if err != nil {
// rootLog.Error("error in init db", "err", err.Error())
// os.Exit(EXIT_FAIL)
// }
/*
- Case 1: Database file does not exist. Created with sql.Open()
- Case 2: Database file exists but table _sft_db does is not created. Created using 0_init.sql
0_init.sql is always run
- Case 3: 0_init.sql is run, but next does not point to 1_*.sql.
creating a migration needs to run sql commands.
Or, check all migrations if they exist in the table, do not run.
It is critical to run in order. In this case we don't need the next column
*/
// TODO: apply rev 0
f, _ := migrations.DbMigrations.Open("0_init.sql")
defer f.Close()
initSql, err := io.ReadAll(f)
if err != nil {
rootLog.Error("can not read embedFS db rev0", "err", err.Error())
os.Exit(EXIT_FAIL)
}
rootLog.Debug("migrations read", "bytes", len(initSql))
_, err = db.Exec(string(initSql))
if err != nil {
rootLog.Error("failed to exec db init for rev0", "err", err.Error())
rootLog.Warn("assume rev0 applied")
}
rootLog.Info("db initilized")
rootLog.Debug("read on _sft_db")
rows, err := db.Query("select db_rev, sft_ver from _sft_db;")
if err != nil {
rootLog.Error("error in init db", "err", err.Error())
os.Exit(EXIT_FAIL)
}
if !rows.Next() {
rootLog.Warn("db not migrated to 1")
}
rows.Close()
// At this point we don't have fixed file names for migrations. Use prefix to increment
// Read all file names in embedFS and Query all migrations in _sft_db
rows, err = db.Query("SELECT db_rev FROM _sft_db;")
if err != nil {
rootLog.Error("error", "err", err.Error())
os.Exit(EXIT_FAIL)
}
var appliedMigs []int
for rows.Next() {
var val int
if err := rows.Scan(&val); err != nil {
rootLog.Error("revision stored in db is not an integer")
os.Exit(EXIT_FAIL)
}
rootLog.Debug("found applied database revision", "rev", val)
appliedMigs = append(appliedMigs, val)
}
rows.Close()
migrationFilesDir, err := migrations.DbMigrations.ReadDir(".")
if err != nil {
rootLog.Error("could not list files in migrations embedFS", "err", err.Error())
os.Exit(EXIT_FAIL)
}
type Migration struct {
Filename string
Rev int
Data []byte
}
var migs []Migration
for i, file := range migrationFilesDir {
if file.IsDir() {
rootLog.Debug("migration dir. skipping", "name", file.Name())
continue
}
rootLog.Debug("migration file", "name", file.Name())
migStmts, err := migrations.DbMigrations.ReadFile(file.Name())
if err != nil {
rootLog.Error("could not read migration file", "err", err.Error(), "name", file.Name())
os.Exit(EXIT_FAIL)
}
mRev, err := strconv.Atoi(strings.Split(file.Name(), "_")[0])
if err != nil {
rootLog.Error(
"unexpected db revision. invalid file naming in migrations",
"err", err.Error(),
"name", file.Name())
os.Exit(EXIT_FAIL)
}
if mRev != i {
rootLog.Error("unexpected migration sequence", "found", mRev, "expected", i)
os.Exit(EXIT_FAIL)
}
rev := Migration{
Filename: file.Name(),
Data: migStmts,
Rev: mRev,
}
migs = append(migs, rev)
rootLog.Debug("loaded db migration", "name", rev.Filename)
// FIXME: this logic is bad when we have 2 or more migrations pending
dbTx, err := db.Begin()
if err != nil {
rootLog.Error("failed to start migration Tx")
os.Exit(EXIT_FAIL)
}
if rev.Rev >= len(appliedMigs) {
rootLog.Info("applying migration", "name", rev.Filename)
_, err = dbTx.Exec(string(rev.Data))
if err != nil {
rootLog.Error("could not apply database migration", "err", err.Error())
dbTx.Rollback()
os.Exit(EXIT_FAIL)
}
rootLog.Debug("committing migration", "name", rev.Filename)
rootLog.Info("applied migration", "name", rev.Filename)
}
err = dbTx.Commit()
if err != nil {
rootLog.Error("failed to commit migration", "err", err.Error(), "name", rev.Filename)
os.Exit(EXIT_FAIL)
}
// NOTE: Expect migs to be ordered 0..1..2...
}
rootLog.Info("database up-to-date")
// TODO: Create local admin users
// TODO: File transfer, quarintine, scan with local accounts
}
/*
initilize database with sft parameters and table from starting point
*/
func initDb(db sql.DB) {
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module gitea.hbanafa.com/HeshamTB/sft
go 1.25.6
require github.com/mattn/go-sqlite3 v1.14.34 // indirect

11
migrations/0_init.sql Normal file
View File

@ -0,0 +1,11 @@
/* SFT database migrations init */
CREATE TABLE IF NOT EXISTS _sft_db (
db_rev INTEGER PRIMARY KEY, -- migration filename without sql suffix (.sql)
sft_ver TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- DELETE FROM _sft_db;
INSERT INTO _sft_db (db_rev, sft_ver, applied_at) VALUES (0, "0.0.1-pre-alpha1", CURRENT_TIMESTAMP);

10
migrations/1_rev1.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS _sft_migtest_table (
id integer PRIMARY KEY
);
INSERT INTO _sft_migtest_table (id) VALUES (99);
INSERT INTO _sft_db (db_rev, sft_ver, applied_at) VALUES (1, "0.0.1-pre-alpha1", CURRENT_TIMESTAMP);

10
migrations/2_rev2.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS _sft_migtest_table2 (
id integer PRIMARY KEY
);
INSERT INTO _sft_migtest_table (id) VALUES (42);
INSERT INTO _sft_db (db_rev, sft_ver, applied_at) VALUES (2, "0.0.1-pre-alpha1", CURRENT_TIMESTAMP);

9
migrations/3_rev3.sql Normal file
View File

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS _sft_migtest_table3 (
id integer PRIMARY KEY
);
INSERT INTO _sft_migtest_table (id) VALUES (42);
INSERT INTO _sft_db (db_rev, sft_ver, applied_at) VALUES (3, "0.0.1-pre-alpha1", CURRENT_TIMESTAMP);

8
migrations/4_rev4.sql Normal file
View File

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS _sft_migtest_table4 (
id integer PRIMARY KEY
);
INSERT INTO _sft_migtest_table (id) VALUES (42);
INSERT INTO _sft_db (db_rev, sft_ver, applied_at) VALUES (4, "0.0.1-pre-alpha1", CURRENT_TIMESTAMP);

7
migrations/migrations.go Normal file
View File

@ -0,0 +1,7 @@
package migrations
import "embed"
//go:embed *.sql
var DbMigrations embed.FS