Files
simplegit/state/db.go
T
2026-07-16 08:42:21 -04:00

243 lines
7.2 KiB
Go

// Database engine opener for simplegit's private DB.
//
// Two backends, selected by URI scheme:
//
// sqlite://<path> -> modernc.org/sqlite (pure Go, no CGO; single binary)
// postgres://<dsn-url> -> github.com/lib/pq
//
// Both are driven through xorm (see models.go's InitEngine). modernc registers
// its driver under the name "sqlite" by default, but xorm keys its sqlite
// dialect off "sqlite3" -- so we re-register modernc's driver as "sqlite3" in
// init(). This is the same trick simpleci's driver_sqlite_modernc.go uses, and
// it keeps CGO out of the build (unlike mattn/go-sqlite3).
package state
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"simplegit/common"
"simplegit/gitcmd"
"strings"
"sync"
_ "github.com/lib/pq" // postgres driver, registered as "postgres"
"golang.org/x/crypto/ssh"
"modernc.org/sqlite" // pure-Go sqlite driver; re-registered as "sqlite3" below
"xorm.io/xorm"
)
const sqliteDriver = "sqlite3"
type IState interface {
Open(owner, name string) (*gitcmd.Repository, error)
// should check database, if not exist, return err directly
RepoPath(owner, name string) (string, error)
KnownSSHKey(key ssh.PublicKey) (bool, error)
AccessBySSHKey(ctx context.Context, key ssh.PublicKey, repons, reponame string, perm common.Perm) (ok bool, err error)
AccessByPAT(ctx context.Context, pat, repons, reponame string, perm common.Perm) (ok bool, err error)
Signers() ([]ssh.Signer, error)
}
type IStateMut interface {
IState
MoveNS(old, new string) error
MoveRepo(old, new string) error
CreateRepo(ns, name string) error
ExistRepo(ns, name string) error
DeleteRepo(ns, name string) error
ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm common.Perm) (aclID int64, err error)
ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (aclID int64, err error)
ACLUpsertSSHKeyOnRepo(reponame string, key ssh.PublicKey, perm common.Perm) (aclID int64, err error)
ACLUpsertPATOnRepo(reponame string, pat string, perm common.Perm) (aclID int64, err error)
ACLDelete(id int64) error
}
// Compile-time guarantees that LocalState satisfies both views.
var (
_ IState = (*LocalState)(nil)
_ IStateMut = (*LocalState)(nil)
)
type LocalState struct {
engine *xorm.Engine
cfg *common.Config
signerMu sync.Mutex
signers []ssh.Signer
signerDone bool
}
func init() {
// Register modernc's driver under "sqlite3" so xorm picks up its sqlite
// dialect while the actual driver stays CGO-free.
sql.Register(sqliteDriver, &sqlite.Driver{})
}
func Open(cfg *common.Config) (*LocalState, error) {
engine, err := InitEngine(cfg.Driver(), cfg.DSN())
if err != nil {
return nil, fmt.Errorf("state: init engine: %w", err)
}
if cfg.Driver() == sqliteDriver {
engine.SetMaxOpenConns(1)
}
log.Printf("state: RepoRoot: %s", cfg.RepoRoot())
log.Printf("state: DB engine %s opened, %s", cfg.Driver(), cfg.DSN())
return &LocalState{
engine: engine,
cfg: cfg,
}, nil
}
// Signer returns the SSH host key, creating and persisting it on first use.
// Cached so the key is stable for the process lifetime.
func (s *LocalState) Signers() ([]ssh.Signer, error) {
s.signerMu.Lock()
defer s.signerMu.Unlock()
if s.signerDone {
return s.signers, nil
}
s.signerDone = true
ed25519signer, err := s.loadOrCreateHostKey("ed25519")
if err != nil {
return nil, err
}
rsasigner, err := s.loadOrCreateHostKey("rsa")
if err != nil {
return nil, err
}
ecdsasigner, err := s.loadOrCreateHostKey("ecdsa")
if err != nil {
return nil, err
}
s.signers = append(s.signers, ed25519signer, rsasigner, ecdsasigner)
return s.signers, err
}
func (s *LocalState) loadOrCreateHostKey(ty string) (ssh.Signer, error) {
var der []byte
switch ty {
case "ed25519":
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
der, err = x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
case "rsa":
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
der, err = x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
case "ecdsa":
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
der, err = x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
default:
return nil, errors.New("loadOrCreateHostKey")
}
hostKeyPath := filepath.Join(s.cfg.Root, fmt.Sprintf("host_key_%v.pem", ty))
if data, err := os.ReadFile(hostKeyPath); err == nil {
return ssh.ParsePrivateKey(data)
} else if !os.IsNotExist(err) {
return nil, err
}
data := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
if err := os.MkdirAll(filepath.Dir(hostKeyPath), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(hostKeyPath, data, 0o600); err != nil {
return nil, err
}
return ssh.ParsePrivateKey(data)
}
func fingerprint(key ssh.PublicKey) string {
sum := sha256.Sum256(key.Marshal())
return "SHA256:" + hex.EncodeToString(sum[:])
}
// normalizeName strips one trailing ".git" so both "myrepo" and "myrepo.git"
// refer to the same repo.
func normalizeName(name string) string {
return strings.TrimSuffix(name, ".git")
}
// relPath returns the repo path relative to root: "owner/name.git".
func relPath(owner, name string) string {
return owner + "/" + normalizeName(name) + ".git"
}
// findRepo looks up a repo by (owner, name). name may include ".git". A missing
// row yields ErrRepoNotFound.
func (s *LocalState) findRepo(owner, name string) (*Repo, error) {
var r Repo
has, err := s.engine.Where("namespace = ? AND name = ?", owner, normalizeName(name)).Get(&r)
if err != nil {
return nil, fmt.Errorf("lookup repo: %w", err)
}
if !has {
return nil, ErrRepoNotFound
}
return &r, nil
}
// RepoPath returns the absolute on-disk path for owner/name, but only if the
// repo is registered in the DB -- the DB is the authority, not the filesystem.
// In skip-auth mode the DB check is skipped (any path under root resolves).
func (s *LocalState) RepoPath(owner, name string) (string, error) {
name = normalizeName(name)
rel := relPath(owner, name)
if _, err := s.findRepo(owner, name); err != nil {
return "", err
}
return common.Resolve(s.cfg.RepoRoot(), rel)
}
// Open returns a gitcmd.Repository for owner/name. The repo must be registered
// (RepoPath checks the DB) and present on disk. In skip-auth mode only the disk
// presence is required.
func (s *LocalState) Open(owner, name string) (*gitcmd.Repository, error) {
path, err := s.RepoPath(owner, name)
if err != nil {
return nil, err
}
return gitcmd.OpenRepository(context.Background(), path)
}
// Engine returns the underlying xorm engine. It is the read egress for the
// ManageService inspection RPCs (ListRepos / GetRepoGrants): those queries live
// in the gitrpc server layer, so state itself stays free of List*/management-
// read methods (only access checks + Updater writes live here). nil in skip-auth
// mode -- but ManageService is disabled under skip-auth, so no caller is reached
// with a nil engine.
func (s *LocalState) Engine() *xorm.Engine { return s.engine }