Compare commits

...

3 Commits

38 changed files with 3578 additions and 61 deletions
+8 -6
View File
@@ -22,9 +22,7 @@ func (paths *configPathList) String() string {
out = ""
for i = 0; i < len(*paths); i++ {
if i > 0 {
out += ","
}
if i > 0 { out += "," }
out += (*paths)[i]
}
return out
@@ -49,16 +47,20 @@ func run() int {
var cfg codit.Config
var logger codit.Logger
var server *codit.Server
var serverId string
var logFile string
var err error
flag.Var(&configPaths, "config", "path or wildcard pattern to YAML configuration file; repeatable")
flag.Var(&configPaths, "config-file", "path to YAML configuration file; repeatable")
flag.Var(&configPaths, "config-file-pattern", "wildcard pattern for YAML configuration files; repeatable")
flag.StringVar(&serverId, "id", "", "set the internal identifer")
flag.StringVar(&logFile, "log-file", "", "path to log file")
flag.Parse()
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, codit.EnvPrefixFromServerId(PROGRAM_NAME))
if serverId == "" { serverId = PROGRAM_NAME }
cfg, err = codit.LoadConfigFilesWithEnvPrefix(configPaths, codit.EnvPrefixFromServerId(serverId))
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: load config - %s\n", err.Error())
return 1
@@ -66,7 +68,7 @@ func run() int {
if logFile != "" {
cfg.App.LogFile = logFile
}
logger, err = newLoggerFromConfig(PROGRAM_NAME, cfg)
logger, err = newLoggerFromConfig(serverId, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: init logger - %s\n", err.Error())
return 1
@@ -77,7 +79,7 @@ func run() int {
logger.Write("", codit.LOG_INFO, "starting %s %s", PROGRAM_NAME, PROGRAM_VERSION)
defer logger.Write("", codit.LOG_INFO, "stopped %s %s", PROGRAM_NAME, PROGRAM_VERSION)
server, err = codit.NewServerWithId(&cfg, logger, PROGRAM_NAME)
server, err = codit.NewServerWithId(&cfg, logger, serverId)
if err != nil {
logger.Write("", codit.LOG_ERROR, "init app - %s", err.Error())
return 1
+6
View File
@@ -4,6 +4,7 @@ go 1.25.3
require (
git2go v0.0.0
github.com/coreos/go-oidc/v3 v3.20.0
github.com/gdamore/tcell/v2 v2.13.8
github.com/go-ldap/ldap/v3 v3.4.6
github.com/goccy/go-yaml v1.19.0
@@ -17,6 +18,11 @@ require (
repokit v0.0.0
)
require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
+6
View File
@@ -8,6 +8,8 @@ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3Uu
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@@ -21,6 +23,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
@@ -91,6 +95,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+15 -6
View File
@@ -42,6 +42,10 @@ func scanAuthProvider(row interface {
&item.OIDCAdmissionExpr,
&item.OIDCEndSessionURL,
&item.OIDCPostLogoutRedirect,
&item.OIDCIssuer,
&item.OIDCRevalidationMode,
&item.OIDCIntrospectionURL,
&item.OIDCFrontChannelLogoutEnabled,
&item.GroupSyncMode,
&defaultUserGroupID,
&item.UserCount,
@@ -61,7 +65,8 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.oidc_post_logout_redirect, p.group_sync_mode,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.oidc_post_logout_redirect, p.oidc_issuer,
p.oidc_revalidation_mode, p.oidc_introspection_url, p.oidc_frontchannel_logout_enabled, p.group_sync_mode,
COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at`
@@ -299,16 +304,18 @@ func (s *Store) CreateAuthProvider(ctx context.Context, item models.AuthProvider
ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, oidc_post_logout_redirect, group_sync_mode,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, oidc_post_logout_redirect, oidc_issuer,
oidc_revalidation_mode, oidc_introspection_url, oidc_frontchannel_logout_enabled, group_sync_mode,
default_user_group_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.OIDCIssuer,
item.OIDCRevalidationMode, item.OIDCIntrospectionURL, item.OIDCFrontChannelLogoutEnabled, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt,
)
@@ -357,7 +364,8 @@ func (s *Store) UpdateAuthProvider(ctx context.Context, item models.AuthProvider
ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, oidc_post_logout_redirect = ?, group_sync_mode = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, oidc_post_logout_redirect = ?, oidc_issuer = ?,
oidc_revalidation_mode = ?, oidc_introspection_url = ?, oidc_frontchannel_logout_enabled = ?, group_sync_mode = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ?
WHERE public_id = ?`,
@@ -366,7 +374,8 @@ func (s *Store) UpdateAuthProvider(ctx context.Context, item models.AuthProvider
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.OIDCIssuer,
item.OIDCRevalidationMode, item.OIDCIntrospectionURL, item.OIDCFrontChannelLogoutEnabled, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt,
strings.TrimSpace(item.ID),
+59
View File
@@ -155,6 +155,65 @@ func (s *Store) DeleteBlockComment(boardID, blockID, commentID string) error {
return nil
}
// UpdateBlockComment rewrites a comment's content (author-scoped: the update
// only matches when created_by is the given user). Returns the refreshed
// comment, or ErrCommentNotFound when nothing matched (missing, deleted, or
// not the author's).
func (s *Store) UpdateBlockComment(ctx context.Context, boardID, blockID, commentID, content, userID string) (models.BlockComment, error) {
var now int64
var result sql.Result
var n int64
var err error
var bc models.BlockComment
var tx txExecutor
var owned bool
now = time.Now().Unix()
tx, owned, err = s.beginImmediateContext(ctx)
if err != nil {
return bc, err
}
result, err = tx.Exec(`
UPDATE block_comments SET content = ?, updated_at = ?
WHERE public_id = ?
AND delete_at = 0
AND created_by = (SELECT id FROM users WHERE public_id = ?)
AND block_id = (
SELECT bl.id FROM blocks bl
JOIN boards brd ON brd.id = bl.board_id
WHERE brd.public_id = ? AND bl.public_id = ?
)`, content, now, commentID, userID, boardID, blockID)
if err != nil {
rollbackIfOwned(tx, owned)
return bc, err
}
n, err = result.RowsAffected()
if err != nil {
rollbackIfOwned(tx, owned)
return bc, err
}
if n == 0 {
rollbackIfOwned(tx, owned)
return bc, ErrCommentNotFound
}
err = tx.QueryRow(
`SELECT`+blockCommentSelectCols+blockCommentSelectFrom+`
WHERE c.public_id = ?`, commentID).Scan(
&bc.ID, &bc.BlockID, &bc.Content,
&bc.CreatedBy, &bc.CreatedByName,
&bc.CreatedAt, &bc.UpdatedAt, &bc.DeleteAt)
if err != nil {
rollbackIfOwned(tx, owned)
return bc, err
}
err = commitIfOwned(tx, owned)
if err != nil {
return bc, err
}
return bc, nil
}
const blockChecklistItemSelectCols = `
ci.public_id, bl.public_id,
ci.title, ci.done, ci.display_order,
+361
View File
@@ -0,0 +1,361 @@
package db
import "context"
import "database/sql"
import "fmt"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) UpsertDockerMirrorConfig(item models.DockerMirrorConfig) error {
var now int64
var err error
now = time.Now().UTC().Unix()
if item.SyncIntervalSec <= 0 { item.SyncIntervalSec = 300 }
_, err = s.Exec(`
INSERT INTO docker_mirrors (repo_id, image, remote_url, tags, username, password, connect_host, host_header, tls_server_name, tls_insecure_skip_verify, pki_client_cert_id, sync_interval_sec, sync_enabled, dirty, next_sync_at, created_at, updated_at)
VALUES ((SELECT id FROM repos WHERE public_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE COALESCE((SELECT id FROM pki_certs WHERE public_id = ?), -1) END, ?, ?, 1, 0, ?, ?)
ON CONFLICT(repo_id, image) DO UPDATE SET
remote_url = excluded.remote_url,
tags = excluded.tags,
username = excluded.username,
password = CASE WHEN excluded.password = '' THEN docker_mirrors.password ELSE excluded.password END,
connect_host = excluded.connect_host,
host_header = excluded.host_header,
tls_server_name = excluded.tls_server_name,
tls_insecure_skip_verify = excluded.tls_insecure_skip_verify,
pki_client_cert_id = excluded.pki_client_cert_id,
sync_interval_sec = excluded.sync_interval_sec,
sync_enabled = excluded.sync_enabled,
dirty = 1,
next_sync_at = 0,
updated_at = excluded.updated_at`,
item.RepoID, item.Image, item.RemoteURL, item.Tags, item.Username, item.Password, item.ConnectHost, item.HostHeader, item.TLSServerName, item.TLSInsecureSkipVerify, item.PKIClientCertID, item.PKIClientCertID, item.SyncIntervalSec, item.SyncEnabled, now, now)
return err
}
func (s *Store) DeleteDockerMirrorConfig(repoID string, image string) error {
var res sql.Result
var err error
var n int64
var running bool
res, err = s.Exec(`DELETE FROM docker_mirrors WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ? AND sync_running = 0`, repoID, image)
if err != nil { return err }
n, err = res.RowsAffected()
if err != nil { return err }
if n <= 0 {
err = s.QueryRow(`SELECT sync_running FROM docker_mirrors WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`, repoID, image).Scan(&running)
if err == nil && running { return fmt.Errorf("docker mirror is running") }
return sql.ErrNoRows
}
return nil
}
func (s *Store) GetDockerMirrorConfig(repoID string, image string) (models.DockerMirrorConfig, error) {
var item models.DockerMirrorConfig
var row *sql.Row
var err error
row = s.QueryRow(`
SELECT r.public_id, m.image, m.remote_url, m.tags, m.username, m.password, m.connect_host, m.host_header, m.tls_server_name, m.tls_insecure_skip_verify, COALESCE(c.public_id, ''), m.sync_interval_sec,
m.sync_enabled, m.dirty, m.next_sync_at, m.sync_running, m.sync_status, m.sync_error,
m.sync_step, m.sync_total, m.sync_done, m.sync_failed, m.last_sync_started_at,
m.last_sync_finished_at, m.last_sync_success_at, m.last_synced_revision, m.created_at, m.updated_at
FROM docker_mirrors m
JOIN repos r ON r.id = m.repo_id
LEFT JOIN pki_certs c ON c.id = m.pki_client_cert_id
WHERE r.public_id = ? AND m.image = ?`, repoID, image)
err = row.Scan(&item.RepoID, &item.Image, &item.RemoteURL, &item.Tags, &item.Username, &item.Password, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.PKIClientCertID, &item.SyncIntervalSec,
&item.SyncEnabled, &item.Dirty, &item.NextSyncAt, &item.SyncRunning, &item.SyncStatus, &item.SyncError,
&item.SyncStep, &item.SyncTotal, &item.SyncDone, &item.SyncFailed, &item.LastSyncStartedAt,
&item.LastSyncFinishedAt, &item.LastSyncSuccessAt, &item.LastSyncedRevision, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
func (s *Store) ListDueDockerMirrorTasks(now int64, limit int) ([]models.DockerMirrorTask, error) {
var rows *sql.Rows
var err error
var out []models.DockerMirrorTask
var item models.DockerMirrorTask
if limit <= 0 { limit = 8 }
rows, err = s.Query(`
SELECT r.public_id, r.path, m.image, m.remote_url, m.tags, m.username, m.password, m.connect_host, m.host_header, m.tls_server_name, m.tls_insecure_skip_verify, COALESCE(c.public_id, ''), m.sync_interval_sec, m.dirty
FROM docker_mirrors m
JOIN repos r ON r.id = m.repo_id
LEFT JOIN pki_certs c ON c.id = m.pki_client_cert_id
WHERE r.type = 'docker' AND m.sync_enabled = 1 AND m.sync_running = 0 AND (m.next_sync_at <= ? OR m.next_sync_at = 0)
ORDER BY m.next_sync_at ASC, m.updated_at ASC
LIMIT ?`, now, limit)
if err != nil { return nil, err }
defer rows.Close()
out = []models.DockerMirrorTask{}
for rows.Next() {
err = rows.Scan(&item.RepoID, &item.RepoPath, &item.Image, &item.RemoteURL, &item.Tags, &item.Username, &item.Password, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.PKIClientCertID, &item.SyncIntervalSec, &item.Dirty)
if err != nil { return nil, err }
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) ListDockerMirrorTasks() ([]models.DockerMirrorTask, error) {
var rows *sql.Rows
var err error
var out []models.DockerMirrorTask
var item models.DockerMirrorTask
rows, err = s.Query(`
SELECT r.public_id, r.path, m.image, m.remote_url, m.tags, m.username, m.password, m.connect_host, m.host_header, m.tls_server_name, m.tls_insecure_skip_verify, COALESCE(c.public_id, ''), m.sync_interval_sec, m.dirty
FROM docker_mirrors m
JOIN repos r ON r.id = m.repo_id
LEFT JOIN pki_certs c ON c.id = m.pki_client_cert_id
WHERE r.type = 'docker'`)
if err != nil { return nil, err }
defer rows.Close()
out = []models.DockerMirrorTask{}
for rows.Next() {
err = rows.Scan(&item.RepoID, &item.RepoPath, &item.Image, &item.RemoteURL, &item.Tags, &item.Username, &item.Password, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.PKIClientCertID, &item.SyncIntervalSec, &item.Dirty)
if err != nil { return nil, err }
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) TryStartDockerMirrorRun(ctx context.Context, repoID string, image string, startedAt int64) (string, bool, error) {
var tx *Store
var err error
var res sql.Result
var n int64
var runID string
tx, err = s.BeginImmediateStore(ctx)
if err != nil { return "", false, err }
defer func() {
if err != nil { _ = tx.Rollback() }
}()
res, err = tx.Exec(`UPDATE docker_mirrors SET sync_running = 1, sync_status = 'running', sync_error = '', sync_step = 'start', sync_total = 0, sync_done = 0, sync_failed = 0, last_sync_started_at = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ? AND sync_enabled = 1 AND sync_running = 0`, startedAt, startedAt, repoID, image)
if err != nil { return "", false, err }
n, err = res.RowsAffected()
if err != nil { return "", false, err }
if n <= 0 {
err = tx.Rollback()
return "", false, err
}
runID, err = util.NewID()
if err != nil { return "", false, err }
_, err = tx.Exec(`INSERT INTO docker_mirror_runs (public_id, repo_id, image, started_at, status) VALUES (?, (SELECT id FROM repos WHERE public_id = ?), ?, ?, 'running')`, runID, repoID, image, startedAt)
if err != nil { return "", false, err }
err = tx.Commit()
if err != nil { return "", false, err }
return runID, true, nil
}
func (s *Store) UpdateDockerMirrorProgress(repoID string, image string, step string, total int64, done int64, failed int64) error {
var err error
_, err = s.Exec(`UPDATE docker_mirrors SET sync_step = ?, sync_total = ?, sync_done = ?, sync_failed = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`,
step, total, done, failed, time.Now().UTC().Unix(), repoID, image)
return err
}
func (s *Store) FinishDockerMirrorRun(ctx context.Context, repoID string, image string, runID string, success bool, finishedAt int64, step string, total int64, done int64, failed int64, revision string, errMsg string, minRetryDelay int64) error {
var tx *Store
var err error
var status string
var nextSync int64
var retryDelay int64
var prevSuccessAt int64
var prevFinishedAt int64
var prevNextSync int64
var prevRetryDelay int64
var maxRetryDelay int64
var interval int64
var prevState string
var row *sql.Row
tx, err = s.BeginImmediateStore(ctx)
if err != nil { return err }
defer func() {
if err != nil { _ = tx.Rollback() }
}()
row = tx.QueryRow(`SELECT sync_interval_sec, last_sync_success_at, last_sync_finished_at, next_sync_at FROM docker_mirrors WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`, repoID, image)
err = row.Scan(&interval, &prevSuccessAt, &prevFinishedAt, &prevNextSync)
if err != nil { return err }
if interval <= 0 { interval = 300 }
if prevFinishedAt == 0 {
prevState = "none"
} else if prevSuccessAt == prevFinishedAt {
prevState = "success"
} else {
prevState = "failed"
}
status = "success"
if success {
_, err = tx.Exec(`UPDATE docker_mirrors SET sync_running = 0, dirty = 0, next_sync_at = ? + sync_interval_sec, sync_status = 'success', sync_error = '', sync_step = ?, sync_total = ?, sync_done = ?, sync_failed = ?, last_sync_finished_at = ?, last_sync_success_at = ?, last_synced_revision = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`,
finishedAt, step, total, done, failed, finishedAt, finishedAt, revision, finishedAt, repoID, image)
if err != nil { return err }
_, err = tx.Exec(`UPDATE docker_mirror_runs SET finished_at = ?, status = 'success', step = ?, total = ?, done = ?, failed = ?, revision = ?, error = '' WHERE public_id = ?`, finishedAt, step, total, done, failed, revision, runID)
if err != nil { return err }
} else {
status = "failed"
if errMsg == "" { errMsg = "docker mirror sync failed" }
maxRetryDelay = interval
if maxRetryDelay < mirrorRetryFloorSec { maxRetryDelay = mirrorRetryFloorSec }
if prevState == "failed" {
prevRetryDelay = prevNextSync - prevFinishedAt
if prevRetryDelay < mirrorRetryFloorSec { prevRetryDelay = mirrorRetryFloorSec }
retryDelay = prevRetryDelay * 2
} else {
retryDelay = mirrorRetryFloorSec
}
if retryDelay > maxRetryDelay { retryDelay = maxRetryDelay }
if retryDelay < mirrorRetryFloorSec { retryDelay = mirrorRetryFloorSec }
if minRetryDelay > retryDelay { retryDelay = minRetryDelay }
nextSync = finishedAt + retryDelay
_, err = tx.Exec(`UPDATE docker_mirrors SET sync_running = 0, dirty = 0, next_sync_at = ?, sync_status = 'failed', sync_error = ?, sync_step = ?, sync_total = ?, sync_done = ?, sync_failed = ?, last_sync_finished_at = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`,
nextSync, errMsg, step, total, done, failed, finishedAt, finishedAt, repoID, image)
if err != nil { return err }
_, err = tx.Exec(`UPDATE docker_mirror_runs SET finished_at = ?, status = ?, step = ?, total = ?, done = ?, failed = ?, revision = ?, error = ? WHERE public_id = ?`, finishedAt, status, step, total, done, failed, revision, errMsg, runID)
if err != nil { return err }
}
err = tx.Commit()
return err
}
func (s *Store) ResetRunningDockerMirrorTasks(ctx context.Context) error {
var tx *Store
var err error
var now int64
now = time.Now().UTC().Unix()
tx, err = s.BeginImmediateStore(ctx)
if err != nil { return err }
defer func() {
if err != nil { _ = tx.Rollback() }
}()
_, err = tx.Exec(`UPDATE docker_mirrors SET sync_running = 0, dirty = 1, next_sync_at = ?, sync_status = 'failed', sync_error = 'aborted by restart', sync_step = 'idle', last_sync_finished_at = ?, updated_at = ? WHERE sync_running = 1`, now+5, now, now)
if err != nil { return err }
_, err = tx.Exec(`UPDATE docker_mirror_runs SET finished_at = ?, status = 'failed', step = CASE WHEN TRIM(COALESCE(step, '')) = '' THEN 'aborted' ELSE step END, error = 'aborted by restart' WHERE status = 'running'`, now)
if err != nil { return err }
err = tx.Commit()
return err
}
func (s *Store) ListRepoDockerMirrorStatus(repoID string) ([]models.DockerMirrorStatus, error) {
return s.listDockerMirrorStatus(`WHERE r.public_id = ?`, repoID)
}
func (s *Store) ListProjectDockerMirrorStatus(projectID string) ([]models.DockerMirrorStatus, error) {
return s.listDockerMirrorStatus(`WHERE p.public_id = ?`, projectID)
}
func (s *Store) ListAdminDockerMirrorStatus() ([]models.DockerMirrorStatus, error) {
return s.listDockerMirrorStatus(``, nil)
}
func (s *Store) listDockerMirrorStatus(where string, arg interface{}) ([]models.DockerMirrorStatus, error) {
var rows *sql.Rows
var err error
var out []models.DockerMirrorStatus
var item models.DockerMirrorStatus
var query string
query = `
SELECT p.public_id, p.slug, r.public_id, r.name, m.image, m.remote_url, m.tags, m.username, m.connect_host, m.host_header, m.tls_server_name, m.tls_insecure_skip_verify, COALESCE(c.public_id, ''), m.sync_interval_sec,
m.sync_enabled, m.dirty, m.next_sync_at, m.sync_running, m.sync_status, m.sync_error,
m.sync_step, m.sync_total, m.sync_done, m.sync_failed, m.last_sync_started_at,
m.last_sync_finished_at, m.last_sync_success_at, m.updated_at
FROM docker_mirrors m
JOIN repos r ON r.id = m.repo_id
LEFT JOIN pki_certs c ON c.id = m.pki_client_cert_id
JOIN projects p ON p.id = r.project_id ` + where + `
ORDER BY p.slug, r.name, m.image`
if arg == nil {
rows, err = s.Query(query)
} else {
rows, err = s.Query(query, arg)
}
if err != nil { return nil, err }
defer rows.Close()
out = []models.DockerMirrorStatus{}
for rows.Next() {
err = rows.Scan(&item.ProjectID, &item.ProjectSlug, &item.RepoID, &item.RepoName, &item.Image, &item.RemoteURL, &item.Tags, &item.Username, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.PKIClientCertID, &item.SyncIntervalSec,
&item.SyncEnabled, &item.Dirty, &item.NextSyncAt, &item.SyncRunning, &item.SyncStatus, &item.SyncError,
&item.SyncStep, &item.SyncTotal, &item.SyncDone, &item.SyncFailed, &item.LastSyncStartedAt,
&item.LastSyncFinishedAt, &item.LastSyncSuccessAt, &item.UpdatedAt)
if err != nil { return nil, err }
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) ListDockerMirrorRuns(repoID string, image string, limit int) ([]models.DockerMirrorRun, error) {
var rows *sql.Rows
var err error
var out []models.DockerMirrorRun
var item models.DockerMirrorRun
if limit <= 0 { limit = 10 }
if limit > 100 { limit = 100 }
rows, err = s.Query(`
SELECT m.public_id, r.public_id, m.image, m.started_at, m.finished_at, m.status, m.step, m.total, m.done, m.failed, m.revision, m.error
FROM docker_mirror_runs m
JOIN repos r ON r.id = m.repo_id
WHERE r.public_id = ? AND m.image = ?
ORDER BY m.started_at DESC
LIMIT ?`, repoID, image, limit)
if err != nil { return nil, err }
defer rows.Close()
out = []models.DockerMirrorRun{}
for rows.Next() {
err = rows.Scan(&item.ID, &item.RepoID, &item.Image, &item.StartedAt, &item.FinishedAt, &item.Status, &item.Step, &item.Total, &item.Done, &item.Failed, &item.Revision, &item.Error)
if err != nil { return nil, err }
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) MarkDockerMirrorDirty(repoID string, image string) error {
var res sql.Result
var err error
var n int64
res, err = s.Exec(`UPDATE docker_mirrors SET dirty = 1, next_sync_at = 0, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?`, time.Now().UTC().Unix(), repoID, image)
if err != nil { return err }
n, err = res.RowsAffected()
if err != nil { return err }
if n <= 0 { return fmt.Errorf("docker mirror not found") }
return nil
}
func (s *Store) CleanupDockerMirrorRunsRetention(repoID string, image string, keepCount int, keepDays int) error {
var cutoff int64
var now int64
var err error
if keepCount <= 0 {
keepCount = 200
}
if keepDays <= 0 {
keepDays = 30
}
now = time.Now().UTC().Unix()
cutoff = now - int64(keepDays*24*60*60)
_, err = s.Exec(`
DELETE FROM docker_mirror_runs
WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?)
AND image = ?
AND started_at < ?
AND id NOT IN (
SELECT id FROM docker_mirror_runs
WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND image = ?
ORDER BY started_at DESC
LIMIT ?
)
`, repoID, image, cutoff, repoID, image, keepCount)
return err
}
+8 -2
View File
@@ -31,6 +31,9 @@ const SettingTimeoutMirrorHTTPSec = "timeouts.mirror_http_sec"
const SettingSessionIdleTimeoutSec = "sessions.idle_timeout_sec"
const SettingSessionHardLimitSec = "sessions.hard_limit_sec"
const SettingOIDCRevalidateIntervalSec = "sessions.oidc_revalidate_interval_sec"
const SettingOIDCRevalidateBatchMax = "sessions.oidc_revalidate_batch_max"
const SettingOIDCRevalidateConcurrency = "sessions.oidc_revalidate_concurrency"
// SettingDef describes one tunable for the admin settings UI + validation.
type SettingDef struct {
@@ -49,8 +52,8 @@ var settingDefs = []SettingDef{
{Key: SettingRetentionMonitorCheckDays, Group: "retention", Label: "Monitor heartbeat retention", Help: "Delete monitor check results older than this.", Unit: "days", Default: 30, Min: 1, Max: 3650},
{Key: SettingRetentionMonitorCheckMax, Group: "retention", Label: "Monitor heartbeats kept per monitor", Help: "Also cap the number of check results kept for each monitor.", Unit: "count", Default: 500, Min: 10, Max: 100000},
{Key: SettingRetentionEventDays, Group: "retention", Label: "Event feed retention", Help: "Delete events (notification feed) older than this.", Unit: "days", Default: 90, Min: 1, Max: 3650},
{Key: SettingRetentionMirrorRunDays, Group: "retention", Label: "RPM mirror-run retention", Help: "Delete RPM mirror run history older than this.", Unit: "days", Default: 30, Min: 1, Max: 3650},
{Key: SettingRetentionMirrorRunMax, Group: "retention", Label: "RPM mirror runs kept per mirror", Help: "Also cap the number of mirror runs kept for each mirror.", Unit: "count", Default: 200, Min: 10, Max: 100000},
{Key: SettingRetentionMirrorRunDays, Group: "retention", Label: "Mirror-run retention", Help: "Delete mirror run history older than this.", Unit: "days", Default: 30, Min: 1, Max: 3650},
{Key: SettingRetentionMirrorRunMax, Group: "retention", Label: "Mirror runs kept per mirror", Help: "Also cap the number of mirror runs kept for each mirror.", Unit: "count", Default: 200, Min: 10, Max: 100000},
// Limits
{Key: SettingLimitMonitorMinIntervalSec, Group: "limits", Label: "Monitor minimum interval", Help: "Smallest check interval a monitor may be set to.", Unit: "seconds", Default: 10, Min: 1, Max: 3600},
@@ -67,6 +70,9 @@ var settingDefs = []SettingDef{
// Sessions
{Key: SettingSessionIdleTimeoutSec, Group: "sessions", Label: "Session idle timeout", Help: "Sign out a web session after this long without genuine activity (background polls don't count). Must exceed the ~5 min renewal window.", Unit: "seconds", Default: 86400, Min: 600, Max: 2592000},
{Key: SettingSessionHardLimitSec, Group: "sessions", Label: "Session maximum lifetime", Help: "Force re-login this long after sign-in regardless of activity. 0 = no hard limit.", Unit: "seconds", Default: 0, Min: 0, Max: 7776000},
{Key: SettingOIDCRevalidateIntervalSec, Group: "sessions", Label: "OIDC session revalidation interval", Help: "How often to re-check each OIDC login against its identity provider so a sign-out elsewhere is reflected here (per-provider mode must be enabled). 0 = disabled. WARNING: a very short interval combined with many active sessions generates a high, sustained request volume to your IdP and can overload or rate-limit (effectively DoS) it — keep this comfortably long unless you understand the load.", Unit: "seconds", Default: 0, Min: 0, Max: 86400},
{Key: SettingOIDCRevalidateBatchMax, Group: "sessions", Label: "OIDC revalidation batch cap", Help: "Maximum OIDC sessions revalidated per cycle — the main brake on IdP load; overflow is picked up on later cycles. WARNING: raising this (especially with a short interval) sharply increases requests to your identity providers and can DoS / get rate-limited by them. Change only for large deployments and monitor the IdP.", Unit: "count", Default: 200, Min: 1, Max: 5000},
{Key: SettingOIDCRevalidateConcurrency, Group: "sessions", Label: "OIDC revalidation concurrency", Help: "Maximum simultaneous revalidation requests to identity providers. WARNING: high values send a burst of concurrent requests and can hammer or trip rate limits (effectively DoS) on the IdP. Increase in small steps only.", Unit: "count", Default: 4, Min: 1, Max: 64},
}
// SettingDefs returns a copy of the registry.
+95 -4
View File
@@ -410,14 +410,105 @@ func (s *Store) ListAPIKeysAdmin(subjectType string, query string) ([]models.Adm
return keys, rows.Err()
}
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string) error {
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string, oidcSid string, oidcRefreshToken string) error {
var err error
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified, oidcIDToken)
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token, oidc_sid, oidc_refresh_token)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified, oidcIDToken, oidcSid, oidcRefreshToken)
return err
}
// RevalidatableSession is one OIDC session due for an outbound liveness check,
// joined to its auth provider's revalidation config.
type RevalidatableSession struct {
ID string
RefreshToken string
Mode string
IntrospectionURL string
TokenURL string
ClientID string
ClientSecret string
TLSInsecureSkipVerify bool
}
// ListRevalidatableSessions returns OIDC sessions whose provider has revalidation
// enabled, that hold a refresh token, are not near expiry, and are due (last
// revalidated at least intervalSec ago) — oldest first, capped at limit. The cap
// bounds IdP load: any overflow is simply picked up on later ticks.
func (s *Store) ListRevalidatableSessions(now int64, nearExpirySkipSec int64, intervalSec int64, limit int) ([]RevalidatableSession, error) {
var rows *sql.Rows
var out []RevalidatableSession
var item RevalidatableSession
var err error
if limit <= 0 {
limit = 200
}
rows, err = s.Query(`SELECT s.id, s.oidc_refresh_token,
p.oidc_revalidation_mode, p.oidc_introspection_url, p.oidc_token_url,
p.oidc_client_id, p.oidc_client_secret, p.oidc_tls_insecure_skip_verify
FROM sessions s
JOIN users u ON u.id = s.user_id
JOIN auth_providers p ON p.id = u.auth_provider_id
WHERE p.oidc_revalidation_mode != '' AND p.oidc_revalidation_mode != 'off'
AND s.oidc_refresh_token != ''
AND (s.expires_at - ?) > ?
AND (? - s.oidc_last_revalidated_at) >= ?
ORDER BY s.oidc_last_revalidated_at ASC
LIMIT ?`, now, nearExpirySkipSec, now, intervalSec, limit)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
item = RevalidatableSession{}
err = rows.Scan(&item.ID, &item.RefreshToken, &item.Mode, &item.IntrospectionURL, &item.TokenURL, &item.ClientID, &item.ClientSecret, &item.TLSInsecureSkipVerify)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
// MarkSessionRevalidated stamps a session as just-checked and stores its (possibly
// rotated) refresh token. Called for sessions that survived a liveness check.
func (s *Store) MarkSessionRevalidated(sessionID string, now int64, refreshToken string) error {
var err error
_, err = s.Exec(`UPDATE sessions SET oidc_last_revalidated_at = ?, oidc_refresh_token = ? WHERE id = ?`, now, refreshToken, sessionID)
return err
}
// DeleteSessionsByUserID removes every session for a user (all devices). Used by
// OIDC back-channel logout when the logout token carries only a `sub`.
func (s *Store) DeleteSessionsByUserID(userID string) (int64, error) {
var result sql.Result
var err error
result, err = s.Exec(`DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE public_id = ?)`, userID)
if err != nil { return 0, err }
return result.RowsAffected()
}
// DeleteSessionsByProviderSid removes sessions matching an IdP session id (`sid`)
// for users belonging to the given auth provider. Used by OIDC back-channel
// logout when the logout token carries a `sid`.
func (s *Store) DeleteSessionsByProviderSid(providerPublicID string, sid string) (int64, error) {
var result sql.Result
var err error
if strings.TrimSpace(sid) == "" { return 0, nil }
result, err = s.Exec(`DELETE FROM sessions
WHERE oidc_sid = ?
AND user_id IN (
SELECT u.id FROM users u
WHERE u.auth_provider_id = (SELECT id FROM auth_providers WHERE public_id = ?)
)`, sid, providerPublicID)
if err != nil { return 0, err }
return result.RowsAffected()
}
// RenewSession slides a session's expiry (used for idle-timeout renewal on
// genuine user activity).
func (s *Store) RenewSession(token string, expiresAt time.Time) error {
+44
View File
@@ -5,6 +5,7 @@ import "crypto/sha256"
import "encoding/hex"
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "os"
import "path/filepath"
@@ -144,6 +145,49 @@ func WriteBlob(repoPath string, digest string, data []byte) error {
return nil
}
func WriteBlobFromReader(repoPath string, digest string, reader io.Reader) (int64, error) {
var path string
var ok bool
var err error
var dir string
var temp string
var out *os.File
var hash hashWriter
var size int64
var computed string
var closeErr error
path, ok = BlobPath(repoPath, digest)
if !ok {
return 0, errors.New("invalid digest")
}
dir = filepath.Dir(path)
err = os.MkdirAll(dir, storage.DirPerm)
if err != nil { return 0, err }
temp = path + ".tmp"
out, err = os.Create(temp)
if err != nil { return 0, err }
hash = newHashWriter()
size, err = io.Copy(io.MultiWriter(out, &hash), reader)
closeErr = out.Close()
if err == nil { err = closeErr }
if err != nil {
_ = os.Remove(temp)
return size, err
}
computed = hash.Digest()
if computed != digest {
_ = os.Remove(temp)
return size, fmt.Errorf("digest mismatch: expected %s got %s", digest, computed)
}
err = os.Rename(temp, path)
if err != nil {
_ = os.Remove(temp)
return size, err
}
return size, nil
}
func ComputeDigest(data []byte) string {
var sum [32]byte
sum = sha256.Sum256(data)
+797
View File
@@ -0,0 +1,797 @@
package docker
import "bytes"
import "context"
import "crypto/tls"
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "net"
import "net/http"
import "net/url"
import "path/filepath"
import "strconv"
import "strings"
import "sync"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/repolock"
import codit_logger "codit/logger"
const logIDDockerMirror string = "docker-mirror"
const dockerMirrorHTTPTimeout time.Duration = 10 * time.Minute
const dockerMirrorRateLimitRetryFloorSec int64 = 300
const mediaTypeDockerManifest string = "application/vnd.docker.distribution.manifest.v2+json"
const mediaTypeDockerManifestList string = "application/vnd.docker.distribution.manifest.list.v2+json"
const mediaTypeDockerConfig string = "application/vnd.docker.container.image.v1+json"
const mediaTypeDockerLayer string = "application/vnd.docker.image.rootfs.diff.tar.gzip"
const mediaTypeOCIManifest string = "application/vnd.oci.image.manifest.v1+json"
const mediaTypeOCIIndex string = "application/vnd.oci.image.index.v1+json"
const mediaTypeOCIConfig string = "application/vnd.oci.image.config.v1+json"
const mediaTypeOCILayer string = "application/vnd.oci.image.layer.v1.tar+gzip"
type MirrorManager struct {
store *db.Store
baseDir string
logger codit_logger.Logger
locks *repolock.Manager
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
activeMu sync.Mutex
active map[string]context.CancelFunc
}
type remoteDockerConfig struct {
BaseURL string
Repository string
Username string
Password string
ConnectHost string
HostHeader string
TLSServerName string
TLSInsecureSkipVerify bool
PKIClientCertID string
ClientCert *tls.Certificate
DefaultHost string
DefaultServer string
}
type remoteDescriptor struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
type remoteManifest struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config remoteDescriptor `json:"config"`
Layers []remoteDescriptor `json:"layers"`
Manifests []remoteDescriptor `json:"manifests"`
}
type remoteTagsResponse struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
type dockerMirrorAuth struct {
Scheme string
Params map[string]string
}
type remoteStatusError struct {
StatusCode int
RetryAfterSec int64
}
func (e remoteStatusError) Error() string {
return fmt.Sprintf("remote registry returned status %d", e.StatusCode)
}
func NewMirrorManager(store *db.Store, baseDir string, logger codit_logger.Logger, locks *repolock.Manager) *MirrorManager {
var m *MirrorManager
m = &MirrorManager{
store: store,
baseDir: baseDir,
logger: logger,
locks: locks,
stopCh: make(chan struct{}),
active: map[string]context.CancelFunc{},
}
return m
}
func (m *MirrorManager) Start(ctx context.Context) error {
var err error
var tasks []models.DockerMirrorTask
var i int
var keepMax int
var keepDays int
if m == nil { return nil }
err = m.store.ResetRunningDockerMirrorTasks(ctx)
if err != nil {
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "reset running tasks failed err=%v", err) }
return err
}
tasks, err = m.store.ListDockerMirrorTasks()
if err != nil {
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "list tasks for retention failed err=%v", err) }
} else {
keepMax = int(m.store.SettingInt(db.SettingRetentionMirrorRunMax))
keepDays = int(m.store.SettingInt(db.SettingRetentionMirrorRunDays))
for i = 0; i < len(tasks); i++ {
_ = m.store.CleanupDockerMirrorRunsRetention(tasks[i].RepoID, tasks[i].Image, keepMax, keepDays)
}
}
m.wg.Add(1)
go m.loop(ctx)
return nil
}
func (m *MirrorManager) Stop() {
if m == nil { return }
m.stopOnce.Do(func() {
var cancel context.CancelFunc
close(m.stopCh)
m.activeMu.Lock()
for _, cancel = range m.active {
cancel()
}
m.activeMu.Unlock()
})
}
func (m *MirrorManager) Wait() {
if m == nil { return }
m.wg.Wait()
}
func (m *MirrorManager) SyncNow(repoID string, image string) error {
return m.store.MarkDockerMirrorDirty(repoID, image)
}
func (m *MirrorManager) loop(ctx context.Context) {
var ticker *time.Ticker
defer m.wg.Done()
ticker = time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
m.runDue(ctx)
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case <-ticker.C:
}
}
}
func (m *MirrorManager) runDue(ctx context.Context) {
var tasks []models.DockerMirrorTask
var err error
var i int
var now int64
var runID string
var started bool
var writeCtx context.Context
var cancel context.CancelFunc
now = time.Now().UTC().Unix()
tasks, err = m.store.ListDueDockerMirrorTasks(now, 4)
if err != nil {
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "list due tasks failed err=%v", err) }
return
}
for i = 0; i < len(tasks); i++ {
writeCtx, cancel = context.WithTimeout(context.Background(), db.IndependentDBOperationTimeout)
runID, started, err = m.store.TryStartDockerMirrorRun(writeCtx, tasks[i].RepoID, tasks[i].Image, now)
cancel()
if err != nil {
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "try start failed repo=%s image=%s err=%v", tasks[i].RepoID, tasks[i].Image, err) }
continue
}
if !started { continue }
m.wg.Add(1)
go m.syncOne(ctx, tasks[i], runID)
}
}
func (m *MirrorManager) syncOne(parent context.Context, task models.DockerMirrorTask, runID string) {
var ctx context.Context
var cancel context.CancelFunc
var key string
var unlock func()
var ok bool
var err error
var total int64
var done int64
var failed int64
var revision string
var step string
var minRetryDelay int64
defer m.wg.Done()
key = task.RepoID + "\x00" + task.Image
ctx, cancel = context.WithCancel(parent)
m.activeMu.Lock()
m.active[key] = cancel
m.activeMu.Unlock()
defer func() {
cancel()
m.activeMu.Lock()
delete(m.active, key)
m.activeMu.Unlock()
}()
if m.locks != nil {
unlock, ok = m.locks.TryLock(task.RepoID)
if !ok {
m.finish(task, runID, false, "lock", 0, 0, 0, "", "repository is busy", 0)
return
}
defer unlock()
}
step = "sync"
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_INFO, "sync start repo=%s image=%s remote=%s", task.RepoID, task.Image, task.RemoteURL) }
total, done, failed, revision, err = m.pullImage(ctx, task)
if err != nil {
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "sync failed repo=%s image=%s err=%v", task.RepoID, task.Image, err) }
minRetryDelay = dockerMirrorRetryDelaySec(err)
m.finish(task, runID, false, step, total, done, failed, revision, err.Error(), minRetryDelay)
return
}
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_INFO, "sync done repo=%s image=%s total=%d done=%d failed=%d revision=%s", task.RepoID, task.Image, total, done, failed, revision) }
m.finish(task, runID, true, step, total, done, failed, revision, "", 0)
}
func (m *MirrorManager) finish(task models.DockerMirrorTask, runID string, success bool, step string, total int64, done int64, failed int64, revision string, errMsg string, minRetryDelay int64) {
var writeCtx context.Context
var cancel context.CancelFunc
var err error
writeCtx, cancel = context.WithTimeout(context.Background(), db.IndependentDBOperationTimeout)
err = m.store.FinishDockerMirrorRun(writeCtx, task.RepoID, task.Image, runID, success, time.Now().UTC().Unix(), step, total, done, failed, revision, errMsg, minRetryDelay)
cancel()
if err != nil && m.logger != nil {
m.logger.Write(logIDDockerMirror, codit_logger.LOG_ERROR, "finish sync state failed repo=%s image=%s run=%s err=%v", task.RepoID, task.Image, runID, err)
}
_ = m.store.CleanupDockerMirrorRunsRetention(task.RepoID, task.Image, int(m.store.SettingInt(db.SettingRetentionMirrorRunMax)), int(m.store.SettingInt(db.SettingRetentionMirrorRunDays)))
}
func (m *MirrorManager) resolveClientCert(ref string) (*tls.Certificate, error) {
var cert models.PKICert
var pair tls.Certificate
var err error
cert, err = m.store.GetPKICert(strings.TrimSpace(ref))
if err != nil { return nil, fmt.Errorf("client certificate not found: %w", err) }
pair, err = tls.X509KeyPair([]byte(cert.CertPEM), []byte(cert.KeyPEM))
if err != nil { return nil, fmt.Errorf("client certificate unusable: %w", err) }
return &pair, nil
}
func (m *MirrorManager) pullImage(ctx context.Context, task models.DockerMirrorTask) (int64, int64, int64, string, error) {
var cfg remoteDockerConfig
var err error
var client *http.Client
var tags []string
var tag string
var i int
var total int64
var done int64
var failed int64
var revision string
var imagePath string
var desc ociDescriptor
cfg, err = parseRemoteDockerURL(task.RemoteURL)
if err != nil { return 0, 0, 0, "", err }
cfg.Username = task.Username
cfg.Password = task.Password
cfg.ConnectHost = strings.TrimSpace(task.ConnectHost)
cfg.HostHeader = strings.TrimSpace(task.HostHeader)
cfg.TLSServerName = strings.TrimSpace(task.TLSServerName)
cfg.TLSInsecureSkipVerify = task.TLSInsecureSkipVerify
cfg.PKIClientCertID = strings.TrimSpace(task.PKIClientCertID)
if cfg.PKIClientCertID != "" {
cfg.ClientCert, err = m.resolveClientCert(cfg.PKIClientCertID)
if err != nil { return 0, 0, 0, "", err }
}
client = dockerMirrorHTTPClient(cfg)
tags = parseTagList(task.Tags)
if len(tags) == 0 {
tags, err = remoteListTags(ctx, client, cfg)
if err != nil { return 0, 0, 0, "", err }
}
total = int64(len(tags))
imagePath = ImagePath(filepath.Join(m.baseDir, storageIDSegmentFromPath(task.RepoPath)), task.Image)
if task.RepoPath != "" && filepath.IsAbs(task.RepoPath) {
imagePath = ImagePath(task.RepoPath, task.Image)
}
err = EnsureLayout(imagePath)
if err != nil { return total, done, failed, revision, err }
for i = 0; i < len(tags); i++ {
select {
case <-ctx.Done():
return total, done, failed, revision, ctx.Err()
default:
}
tag = tags[i]
_ = m.store.UpdateDockerMirrorProgress(task.RepoID, task.Image, "tag:"+tag, total, done, failed)
desc, err = mirrorTag(ctx, client, cfg, imagePath, tag)
if err != nil {
failed++
if m.logger != nil { m.logger.Write(logIDDockerMirror, codit_logger.LOG_WARN, "tag sync failed repo=%s image=%s tag=%s err=%v", task.RepoID, task.Image, tag, err) }
_ = m.store.UpdateDockerMirrorProgress(task.RepoID, task.Image, "tag:"+tag, total, done, failed)
if dockerMirrorRetryDelaySec(err) > 0 { return total, done, failed, revision, err }
continue
}
done++
revision = desc.Digest
_ = m.store.UpdateDockerMirrorProgress(task.RepoID, task.Image, "tag:"+tag, total, done, failed)
}
if failed > 0 {
return total, done, failed, revision, errors.New("some docker tags failed to sync")
}
return total, done, failed, revision, nil
}
func dockerMirrorRetryDelaySec(err error) int64 {
var statusErr remoteStatusError
var ok bool
var delay int64
statusErr, ok = err.(remoteStatusError)
if !ok { return 0 }
if statusErr.StatusCode != http.StatusTooManyRequests { return 0 }
delay = statusErr.RetryAfterSec
if delay < dockerMirrorRateLimitRetryFloorSec { delay = dockerMirrorRateLimitRetryFloorSec }
return delay
}
func storageIDSegmentFromPath(path string) string {
return filepath.Base(path)
}
func mirrorTag(ctx context.Context, client *http.Client, cfg remoteDockerConfig, imagePath string, tag string) (ociDescriptor, error) {
var data []byte
var mediaType string
var digest string
var desc ociDescriptor
var err error
data, mediaType, digest, err = remoteGetBytes(ctx, client, cfg, "manifests/"+url.PathEscape(tag), dockerManifestAcceptHeader())
if err != nil { return desc, err }
if digest == "" {
digest = ComputeDigest(data)
} else if ComputeDigest(data) != digest {
return desc, fmt.Errorf("manifest digest mismatch for tag %s", tag)
}
err = WriteBlob(imagePath, digest, data)
if err != nil { return desc, err }
desc = ociDescriptor{MediaType: mediaType, Digest: digest, Size: int64(len(data))}
err = mirrorManifestChildren(ctx, client, cfg, imagePath, data, mediaType)
if err != nil { return desc, err }
err = withRepoLock(imagePath, func() error {
return updateTag(imagePath, tag, desc)
})
if err != nil { return desc, err }
return desc, nil
}
func mirrorManifestChildren(ctx context.Context, client *http.Client, cfg remoteDockerConfig, imagePath string, data []byte, mediaType string) error {
var mf remoteManifest
var err error
var i int
err = json.Unmarshal(data, &mf)
if err != nil { return err }
if mediaType == mediaTypeDockerManifestList || mediaType == mediaTypeOCIIndex || len(mf.Manifests) > 0 {
for i = 0; i < len(mf.Manifests); i++ {
err = mirrorManifestByDigest(ctx, client, cfg, imagePath, mf.Manifests[i])
if err != nil { return err }
}
return nil
}
if mf.Config.Digest != "" {
err = mirrorBlob(ctx, client, cfg, imagePath, mf.Config.Digest)
if err != nil { return err }
}
for i = 0; i < len(mf.Layers); i++ {
err = mirrorBlob(ctx, client, cfg, imagePath, mf.Layers[i].Digest)
if err != nil { return err }
}
return nil
}
func mirrorManifestByDigest(ctx context.Context, client *http.Client, cfg remoteDockerConfig, imagePath string, desc remoteDescriptor) error {
var data []byte
var mediaType string
var digest string
var err error
data, mediaType, digest, err = remoteGetBytes(ctx, client, cfg, "manifests/"+desc.Digest, dockerManifestAcceptHeader())
if err != nil { return err }
if digest == "" {
digest = ComputeDigest(data)
} else if ComputeDigest(data) != digest {
return fmt.Errorf("manifest digest mismatch for %s", desc.Digest)
}
err = WriteBlob(imagePath, digest, data)
if err != nil { return err }
return mirrorManifestChildren(ctx, client, cfg, imagePath, data, mediaType)
}
func mirrorBlob(ctx context.Context, client *http.Client, cfg remoteDockerConfig, imagePath string, digest string) error {
var ok bool
var err error
var reader io.ReadCloser
ok, err = HasBlob(imagePath, digest)
if err != nil { return err }
if ok { return nil }
reader, err = remoteGetStream(ctx, client, cfg, "blobs/"+digest)
if err != nil { return err }
defer reader.Close()
_, err = WriteBlobFromReader(imagePath, digest, reader)
return err
}
func dockerMirrorHTTPClient(cfg remoteDockerConfig) *http.Client {
var transport *http.Transport
var tlsConfig *tls.Config
tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.TLSInsecureSkipVerify,
ServerName: effectiveServerName(cfg),
}
if cfg.ClientCert != nil {
tlsConfig.Certificates = []tls.Certificate{*cfg.ClientCert}
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: tlsConfig,
}
if strings.TrimSpace(cfg.ConnectHost) != "" {
transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) {
var d net.Dialer
_ = addr
return d.DialContext(ctx, network, cfg.ConnectHost)
}
}
return &http.Client{
Timeout: dockerMirrorHTTPTimeout,
Transport: transport,
}
}
func parseRemoteDockerURL(raw string) (remoteDockerConfig, error) {
var cfg remoteDockerConfig
var parsed *url.URL
var err error
var value string
var host string
var server string
var repo string
value = strings.TrimSpace(raw)
if value == "" { return cfg, errors.New("remote_url is required") }
if !strings.Contains(value, "://") { value = "https://" + value }
parsed, err = url.Parse(value)
if err != nil { return cfg, err }
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return cfg, errors.New("remote_url scheme must be http or https")
}
host = strings.TrimSpace(parsed.Host)
repo = strings.Trim(strings.TrimSpace(parsed.Path), "/")
if host == "" || repo == "" { return cfg, errors.New("remote_url must include registry host and repository path") }
if host == "docker.io" || host == "index.docker.io" {
host = "registry-1.docker.io"
}
if host == "registry-1.docker.io" && !strings.Contains(repo, "/") {
repo = "library/" + repo
}
server = host
if strings.Contains(server, ":") {
server = strings.Split(server, ":")[0]
}
cfg.BaseURL = parsed.Scheme + "://" + host
cfg.Repository = repo
cfg.DefaultHost = host
cfg.DefaultServer = server
return cfg, nil
}
func effectiveHostHeader(cfg remoteDockerConfig) string {
if strings.TrimSpace(cfg.HostHeader) != "" {
return strings.TrimSpace(cfg.HostHeader)
}
return cfg.DefaultHost
}
func effectiveServerName(cfg remoteDockerConfig) string {
var host string
if strings.TrimSpace(cfg.TLSServerName) != "" {
return strings.TrimSpace(cfg.TLSServerName)
}
host = strings.TrimSpace(cfg.HostHeader)
if host != "" {
if strings.Contains(host, ":") {
return strings.Split(host, ":")[0]
}
return host
}
return ""
}
func dockerMirrorTokenHTTPClient(cfg remoteDockerConfig) *http.Client {
var transport *http.Transport
var tlsConfig *tls.Config
tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
if cfg.ClientCert != nil {
tlsConfig.Certificates = []tls.Certificate{*cfg.ClientCert}
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: tlsConfig,
}
return &http.Client{
Timeout: dockerMirrorHTTPTimeout,
Transport: transport,
}
}
func parseTagList(value string) []string {
var fields []string
var out []string
var i int
var item string
value = strings.ReplaceAll(value, "\n", ",")
fields = strings.Split(value, ",")
out = []string{}
for i = 0; i < len(fields); i++ {
item = strings.TrimSpace(fields[i])
if item != "" { out = append(out, item) }
}
return out
}
func remoteListTags(ctx context.Context, client *http.Client, cfg remoteDockerConfig) ([]string, error) {
var data []byte
var resp remoteTagsResponse
var err error
data, _, _, err = remoteGetBytes(ctx, client, cfg, "tags/list", "application/json")
if err != nil { return nil, err }
err = json.Unmarshal(data, &resp)
if err != nil { return nil, err }
if resp.Tags == nil { return []string{}, nil }
return resp.Tags, nil
}
func remoteGetBytes(ctx context.Context, client *http.Client, cfg remoteDockerConfig, rel string, accept string) ([]byte, string, string, error) {
var rc io.ReadCloser
var data []byte
var err error
var mediaType string
var digest string
var response *http.Response
var idx int
response, rc, err = remoteDo(ctx, client, cfg, rel, accept)
if err != nil { return nil, "", "", err }
defer rc.Close()
data, err = io.ReadAll(rc)
if err != nil { return nil, "", "", err }
mediaType = response.Header.Get("Content-Type")
idx = strings.Index(mediaType, ";")
if idx >= 0 { mediaType = strings.TrimSpace(mediaType[:idx]) }
digest = response.Header.Get("Docker-Content-Digest")
if mediaType == "" { mediaType = detectManifestMediaType(data) }
return data, mediaType, digest, nil
}
func remoteGetStream(ctx context.Context, client *http.Client, cfg remoteDockerConfig, rel string) (io.ReadCloser, error) {
var rc io.ReadCloser
var err error
var response *http.Response
response, rc, err = remoteDo(ctx, client, cfg, rel, "")
_ = response
return rc, err
}
func remoteDo(ctx context.Context, client *http.Client, cfg remoteDockerConfig, rel string, accept string) (*http.Response, io.ReadCloser, error) {
var req *http.Request
var resp *http.Response
var err error
var target string
var auth dockerMirrorAuth
var token string
target = strings.TrimRight(cfg.BaseURL, "/") + "/v2/" + strings.Trim(cfg.Repository, "/") + "/" + strings.TrimLeft(rel, "/")
req, err = http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil { return nil, nil, err }
req.Host = effectiveHostHeader(cfg)
if accept != "" { req.Header.Set("Accept", accept) }
resp, err = client.Do(req)
if err != nil { return nil, nil, err }
if resp.StatusCode == http.StatusUnauthorized {
auth = parseDockerAuthHeader(resp.Header.Get("WWW-Authenticate"))
_ = resp.Body.Close()
if strings.EqualFold(auth.Scheme, "Bearer") {
token, err = fetchDockerBearerToken(ctx, cfg, auth)
if err != nil { return nil, nil, err }
req, err = http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil { return nil, nil, err }
req.Host = effectiveHostHeader(cfg)
if accept != "" { req.Header.Set("Accept", accept) }
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil { return nil, nil, err }
} else if strings.EqualFold(auth.Scheme, "Basic") && cfg.Username != "" {
req, err = http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil { return nil, nil, err }
req.Host = effectiveHostHeader(cfg)
if accept != "" { req.Header.Set("Accept", accept) }
req.SetBasicAuth(cfg.Username, cfg.Password)
resp, err = client.Do(req)
if err != nil { return nil, nil, err }
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err = remoteStatusError{StatusCode: resp.StatusCode, RetryAfterSec: parseRetryAfterSec(resp.Header.Get("Retry-After"))}
_ = resp.Body.Close()
return nil, nil, err
}
return resp, resp.Body, nil
}
func fetchDockerBearerToken(ctx context.Context, cfg remoteDockerConfig, auth dockerMirrorAuth) (string, error) {
var endpoint string
var req *http.Request
var resp *http.Response
var tokenClient *http.Client
var err error
var q url.Values
var body []byte
var parsed map[string]interface{}
var token string
var value interface{}
var ok bool
endpoint = auth.Params["realm"]
if endpoint == "" { return "", errors.New("bearer auth realm missing") }
q = url.Values{}
if auth.Params["service"] != "" { q.Set("service", auth.Params["service"]) }
if auth.Params["scope"] != "" { q.Set("scope", auth.Params["scope"]) }
if strings.Contains(endpoint, "?") {
endpoint = endpoint + "&" + q.Encode()
} else {
endpoint = endpoint + "?" + q.Encode()
}
req, err = http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { return "", err }
if cfg.Username != "" { req.SetBasicAuth(cfg.Username, cfg.Password) }
tokenClient = dockerMirrorTokenHTTPClient(cfg)
resp, err = tokenClient.Do(req)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", remoteStatusError{StatusCode: resp.StatusCode, RetryAfterSec: parseRetryAfterSec(resp.Header.Get("Retry-After"))}
}
body, err = io.ReadAll(resp.Body)
if err != nil { return "", err }
err = json.Unmarshal(body, &parsed)
if err != nil { return "", err }
value, ok = parsed["token"]
if ok { token, _ = value.(string) }
if token == "" {
value, ok = parsed["access_token"]
if ok { token, _ = value.(string) }
}
if token == "" { return "", errors.New("token response missing token") }
return token, nil
}
func parseRetryAfterSec(value string) int64 {
var seconds int64
var err error
var when time.Time
value = strings.TrimSpace(value)
if value == "" { return 0 }
seconds, err = strconv.ParseInt(value, 10, 64)
if err == nil {
if seconds < 0 { return 0 }
return seconds
}
when, err = http.ParseTime(value)
if err != nil { return 0 }
seconds = int64(time.Until(when).Seconds())
if seconds < 0 { return 0 }
return seconds
}
func parseDockerAuthHeader(header string) dockerMirrorAuth {
var out dockerMirrorAuth
var parts []string
var rest string
var fields []string
var i int
var field string
var idx int
var key string
var value string
out.Params = map[string]string{}
header = strings.TrimSpace(header)
parts = strings.SplitN(header, " ", 2)
if len(parts) == 0 { return out }
out.Scheme = strings.TrimSpace(parts[0])
if len(parts) < 2 { return out }
rest = parts[1]
fields = splitAuthParams(rest)
for i = 0; i < len(fields); i++ {
field = strings.TrimSpace(fields[i])
idx = strings.Index(field, "=")
if idx <= 0 { continue }
key = strings.TrimSpace(field[:idx])
value = strings.Trim(strings.TrimSpace(field[idx+1:]), `"`)
out.Params[key] = value
}
return out
}
func splitAuthParams(value string) []string {
var out []string
var buf bytes.Buffer
var inQuote bool
var i int
var ch byte
out = []string{}
for i = 0; i < len(value); i++ {
ch = value[i]
if ch == '"' {
inQuote = !inQuote
buf.WriteByte(ch)
continue
}
if ch == ',' && !inQuote {
out = append(out, buf.String())
buf.Reset()
continue
}
buf.WriteByte(ch)
}
if buf.Len() > 0 { out = append(out, buf.String()) }
return out
}
func dockerManifestAcceptHeader() string {
return strings.Join([]string{
mediaTypeDockerManifest,
mediaTypeDockerManifestList,
mediaTypeOCIManifest,
mediaTypeOCIIndex,
"application/vnd.docker.distribution.manifest.v1+json",
}, ", ")
}
+302 -12
View File
@@ -44,6 +44,7 @@ type APIOptions struct {
RpmBase string
RpmMeta *rpm.MetaManager
RpmMirror *rpm.MirrorManager
DockerMirror *docker.MirrorManager
DockerBase string
Uploads storage.FileStore
Logger codit_logger.Logger
@@ -65,6 +66,7 @@ type API struct {
RpmBase string
RpmMeta *rpm.MetaManager
RpmMirror *rpm.MirrorManager
DockerMirror *docker.MirrorManager
DockerBase string
Uploads storage.FileStore
Logger codit_logger.Logger
@@ -103,6 +105,7 @@ func NewAPI(options APIOptions) *API {
RpmBase: options.RpmBase,
RpmMeta: options.RpmMeta,
RpmMirror: options.RpmMirror,
DockerMirror: options.DockerMirror,
DockerBase: options.DockerBase,
Uploads: options.Uploads,
Logger: options.Logger,
@@ -218,6 +221,25 @@ func (api *API) lockResolvedRepoForRead(w http.ResponseWriter, r *http.Request,
return lockedRepo, unlock, true
}
func (api *API) resolveRepoForRole(w http.ResponseWriter, r *http.Request, repoID string, requiredRole string) (models.Repo, bool) {
var repo models.Repo
var err error
repo, err = api.getRepoResolved(api.store(r), repoID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return repo, false
}
if strings.TrimSpace(repo.Path) == "" {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "repository storage is not ready")
return repo, false
}
if !api.requireRepoRole(w, r, repo.ID, requiredRole) {
return repo, false
}
return repo, true
}
func (api *API) serverId() string {
return config.NormalizeServerId(api.ServerId)
}
@@ -503,6 +525,21 @@ type repoDockerRenameImageRequest struct {
To string `json:"to"`
}
type repoDockerMirrorRequest struct {
Image string `json:"image"`
RemoteURL string `json:"remote_url"`
Tags string `json:"tags"`
Username string `json:"username"`
Password string `json:"password"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
PKIClientCertID string `json:"pki_client_cert_id"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
SyncEnabled bool `json:"sync_enabled"`
}
type repoRPMUploadResponse struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
@@ -735,7 +772,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
if required && !user.TOTPEnabled {
sessionVerified = false
}
token, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, "")
token, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, "", "", "")
if err != nil {
_ = txStore.Rollback()
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "session create failed user=%s err=%v", user.Username, err)
@@ -783,7 +820,7 @@ func ldapProviderCompatibleForLogin(original models.AuthProvider, current models
return true
}
func (api *API) createSessionWithStore(store *db.Store, user models.User, totpVerified bool, oidcIDToken string) (string, time.Time, error) {
func (api *API) createSessionWithStore(store *db.Store, user models.User, totpVerified bool, oidcIDToken string, oidcSid string, oidcRefreshToken string) (string, time.Time, error) {
var token string
var err error
var expires time.Time
@@ -802,7 +839,7 @@ func (api *API) createSessionWithStore(store *db.Store, user models.User, totpVe
expires = now.Add(hard)
}
_ = store.DeleteExpiredSessions(now)
err = store.CreateSession(user.ID, token, expires, totpVerified, oidcIDToken)
err = store.CreateSession(user.ID, token, expires, totpVerified, oidcIDToken, oidcSid, oidcRefreshToken)
if err != nil { return "", expires, err }
return token, expires, nil
}
@@ -6029,12 +6066,10 @@ func (api *API) RepoDockerTags(w http.ResponseWriter, r *http.Request, params ma
var tags []docker.TagInfo
var image string
var imagePath string
var unlock func()
var ok bool
repo, unlock, ok = api.lockResolvedRepoForRead(w, r, params["id"], models.RoleViewer)
repo, ok = api.resolveRepoForRole(w, r, params["id"], models.RoleViewer)
if !ok { return }
defer unlock()
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
@@ -6056,12 +6091,10 @@ func (api *API) RepoDockerManifest(w http.ResponseWriter, r *http.Request, param
var detail docker.ManifestDetail
var image string
var imagePath string
var unlock func()
var ok bool
repo, unlock, ok = api.lockResolvedRepoForRead(w, r, params["id"], models.RoleViewer)
repo, ok = api.resolveRepoForRole(w, r, params["id"], models.RoleViewer)
if !ok { return }
defer unlock()
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
@@ -6089,12 +6122,10 @@ func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params
var repo models.Repo
var err error
var images []string
var unlock func()
var ok bool
repo, unlock, ok = api.lockResolvedRepoForRead(w, r, params["id"], models.RoleViewer)
repo, ok = api.resolveRepoForRole(w, r, params["id"], models.RoleViewer)
if !ok { return }
defer unlock()
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
@@ -6107,6 +6138,205 @@ func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params
WriteJSON(w, http.StatusOK, images)
}
func (api *API) RepoDockerMirrors(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var items []models.DockerMirrorStatus
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
if !api.requireRepoRole(w, r, repo.ID, models.RoleViewer) { return }
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
}
items, err = api.store(r).ListRepoDockerMirrorStatus(repo.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, items)
}
func (api *API) RepoDockerUpsertMirror(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var req repoDockerMirrorRequest
var item models.DockerMirrorConfig
var existing models.DockerMirrorConfig
var image string
var pkiClientCertID string
var err error
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
if !api.requireRepoRole(w, r, repo.ID, models.RoleWriter) { return }
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if strings.TrimSpace(req.RemoteURL) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "remote_url required")
return
}
image = strings.Trim(strings.TrimSpace(req.Image), "/")
if image == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "local image required")
return
}
if req.SyncIntervalSec <= 0 { req.SyncIntervalSec = 300 }
pkiClientCertID = strings.TrimSpace(req.PKIClientCertID)
if !api.callerIsAdmin(r) {
if pkiClientCertID != "" {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "client certificate requires full admin")
return
}
existing, err = api.store(r).GetDockerMirrorConfig(repo.ID, image)
if err != nil && err != sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if err == nil {
pkiClientCertID = existing.PKIClientCertID
}
}
err = validateDockerMirrorRemoteConfig(req.RemoteURL, req.ConnectHost, req.HostHeader, req.TLSServerName, req.TLSInsecureSkipVerify, pkiClientCertID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
if pkiClientCertID != "" {
if !api.callerIsAdmin(r) {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "client certificate requires full admin")
return
}
err = validatePKIClientCertForMTLS(api.store(r), pkiClientCertID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
}
item = models.DockerMirrorConfig{
RepoID: repo.ID,
Image: image,
RemoteURL: strings.TrimSpace(req.RemoteURL),
Tags: strings.TrimSpace(req.Tags),
Username: strings.TrimSpace(req.Username),
Password: req.Password,
ConnectHost: strings.TrimSpace(req.ConnectHost),
HostHeader: strings.TrimSpace(req.HostHeader),
TLSServerName: strings.TrimSpace(req.TLSServerName),
TLSInsecureSkipVerify: req.TLSInsecureSkipVerify,
PKIClientCertID: pkiClientCertID,
SyncIntervalSec: req.SyncIntervalSec,
SyncEnabled: req.SyncEnabled,
}
if docker.IsReservedImagePath(item.Image) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid image")
return
}
err = api.store(r).UpsertDockerMirrorConfig(item)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
item.Password = ""
WriteJSON(w, http.StatusOK, item)
}
func (api *API) RepoDockerDeleteMirror(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var image string
var err error
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
if !api.requireRepoRole(w, r, repo.ID, models.RoleWriter) { return }
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
}
image = strings.Trim(strings.TrimSpace(r.URL.Query().Get("image")), "/")
err = api.store(r).DeleteDockerMirrorConfig(repo.ID, image)
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "docker mirror not found")
return
}
if strings.Contains(err.Error(), "running") {
WriteJSONWithErrorReason(w, r, http.StatusConflict, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (api *API) RepoDockerSyncMirror(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var image string
var err error
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
if !api.requireRepoRole(w, r, repo.ID, models.RoleWriter) { return }
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
}
image = strings.Trim(strings.TrimSpace(r.URL.Query().Get("image")), "/")
err = api.store(r).MarkDockerMirrorDirty(repo.ID, image)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "queued"})
}
func (api *API) RepoDockerMirrorRuns(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var image string
var limit int
var err error
var runs []models.DockerMirrorRun
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
if !api.requireRepoRole(w, r, repo.ID, models.RoleViewer) { return }
if repo.Type != models.RepoTypeDocker {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repo is not docker")
return
}
image = strings.Trim(strings.TrimSpace(r.URL.Query().Get("image")), "/")
limit, _ = strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
runs, err = api.store(r).ListDockerMirrorRuns(repo.ID, image, limit)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, runs)
}
func generateAPIKeyToken() (string, string, string, error) {
var buf []byte
var token string
@@ -7458,6 +7688,66 @@ func validateRPMMirrorConfig(mode string, remoteURL string, connectHost string,
return nil
}
func validateDockerMirrorRemoteConfig(remoteURL string, connectHost string, hostHeader string, tlsServerName string, tlsInsecureSkipVerify bool, pkiClientCertID string) error {
var value string
var parsed *url.URL
var err error
value = strings.TrimSpace(remoteURL)
if value == "" { return errors.New("remote_url required") }
if !strings.Contains(value, "://") { value = "https://" + value }
parsed, err = url.Parse(value)
if err != nil { return err }
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("remote_url scheme must be http or https")
}
if strings.TrimSpace(parsed.Host) == "" {
return errors.New("remote_url must include registry host")
}
if strings.Trim(strings.TrimSpace(parsed.Path), "/") == "" {
return errors.New("remote_url must include repository path")
}
if strings.TrimSpace(connectHost) != "" && !strings.Contains(strings.TrimSpace(connectHost), ":") {
return errors.New("connect_host must include port")
}
if parsed.Scheme == "http" {
if strings.TrimSpace(tlsServerName) != "" || tlsInsecureSkipVerify {
return errors.New("TLS options require https remote_url")
}
if strings.TrimSpace(pkiClientCertID) != "" {
return errors.New("client certificate requires https remote_url")
}
}
_ = hostHeader
return nil
}
func validatePKIClientCertForMTLS(store *db.Store, certID string) error {
var cert models.PKICert
var now int64
var err error
certID = strings.TrimSpace(certID)
if certID == "" { return nil }
cert, err = store.GetPKICert(certID)
if err != nil { return errors.New("client certificate not found") }
applyPKICertUsageFlags(&cert)
if cert.IsCA || cert.Status != models.PKIStatusActive || !cert.HasClientAuth {
return errors.New("client certificate is not usable for mTLS")
}
if strings.TrimSpace(cert.CertPEM) == "" || strings.TrimSpace(cert.KeyPEM) == "" {
return errors.New("client certificate does not include private key")
}
now = time.Now().Unix()
if cert.NotAfter > 0 && cert.NotAfter <= now {
return errors.New("client certificate is expired")
}
if cert.NotBefore > 0 && cert.NotBefore > now {
return errors.New("client certificate is not valid yet")
}
return nil
}
func pathUnderRoot(path string, root string) bool {
if root == "" {
return true
+51
View File
@@ -43,6 +43,9 @@ func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params
}
for i = 0; i < len(comments); i++ {
comments[i].CanDelete = user.IsAdmin || comments[i].CreatedBy == user.ID || boardRoleAllows(role, models.RoleAdmin)
// Editing is author-only: admins may remove another user's comment
// but not rewrite its content.
comments[i].CanEdit = comments[i].CreatedBy == user.ID
}
}
WriteJSON(w, http.StatusOK, comments)
@@ -81,6 +84,11 @@ func (api *API) CreateBlockComment(w http.ResponseWriter, r *http.Request, param
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
// The creator is the author, so they can always edit and delete their own
// fresh comment (ListBlockComments would otherwise be the only place these
// get stamped, so the buttons wouldn't show until the card is reopened).
comment.CanDelete = true
comment.CanEdit = true
WriteJSON(w, http.StatusCreated, comment)
}
@@ -142,6 +150,49 @@ func (api *API) DeleteBlockComment(w http.ResponseWriter, r *http.Request, param
w.WriteHeader(http.StatusNoContent)
}
type updateBlockCommentRequest struct {
Content string `json:"content"`
}
func (api *API) UpdateBlockComment(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req updateBlockCommentRequest
var user models.User
var ok bool
var comment models.BlockComment
var content string
var err error
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) {
return
}
err = DecodeJSON(r, &req)
content = strings.TrimSpace(req.Content)
if err != nil || content == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "content is required")
return
}
// Author-only: the store update matches on created_by, so a non-author (even
// a board admin) gets ErrCommentNotFound → 404, consistent with delete's
// "comment not found" for a comment the caller can't see.
comment, err = api.store(r).UpdateBlockComment(r.Context(), params["boardId"], params["blockId"], params["commentId"], content, user.ID)
if err != nil {
if errors.Is(err, db.ErrCommentNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "comment not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
comment.CanDelete = true
comment.CanEdit = true
WriteJSON(w, http.StatusOK, comment)
}
func (api *API) ListBlockChecklistItems(w http.ResponseWriter, r *http.Request, params map[string]string) {
var items []models.BlockChecklistItem
var err error
@@ -0,0 +1,189 @@
package handlers
import "context"
import "encoding/json"
import "errors"
import "net/http"
import "strings"
import "sync"
import "time"
import "github.com/coreos/go-oidc/v3/oidc"
import "codit/internal/models"
import codit_logger "codit/logger"
// oidcBackChannelLogoutEvent is the event URI that must appear in a logout
// token's `events` claim (OpenID Connect Back-Channel Logout 1.0, §2.4).
const oidcBackChannelLogoutEvent string = "http://schemas.openid.net/event/backchannel-logout"
// oidcLogoutTokenClaims is the subset of a back-channel logout token we validate.
type oidcLogoutTokenClaims struct {
Iss string `json:"iss"`
Aud json.RawMessage `json:"aud"` // string or []string
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
Sub string `json:"sub"`
Sid string `json:"sid"`
Nonce string `json:"nonce"`
Events map[string]json.RawMessage `json:"events"`
}
// oidcKeySetCache memoizes a provider's discovered JWKS key set by issuer so
// discovery isn't re-run on every logout call. RemoteKeySet itself caches and
// rotates the individual signing keys.
type oidcKeySetCache struct {
mu sync.Mutex
sets map[string]oidc.KeySet
}
var backChannelKeySets = &oidcKeySetCache{sets: map[string]oidc.KeySet{}}
func (c *oidcKeySetCache) get(ctx context.Context, provider models.AuthProvider) (oidc.KeySet, error) {
var issuer string
var existing oidc.KeySet
var ok bool
var discovered *oidc.Provider
var meta struct {
JWKSURL string `json:"jwks_uri"`
}
var keySet oidc.KeySet
var err error
issuer = strings.TrimSpace(provider.OIDCIssuer)
c.mu.Lock()
defer c.mu.Unlock()
existing, ok = c.sets[issuer]
if ok {
return existing, nil
}
// Discover the JWKS URL from the issuer, dialing through the provider's
// (optionally TLS-skipping) HTTP client. NewProvider also verifies that the
// discovery document's issuer matches the configured one.
discovered, err = oidc.NewProvider(oidc.ClientContext(ctx, oidcHTTPClientFromProvider(provider)), issuer)
if err != nil {
return nil, err
}
err = discovered.Claims(&meta)
if err != nil {
return nil, err
}
if strings.TrimSpace(meta.JWKSURL) == "" {
return nil, errors.New("oidc discovery document has no jwks_uri")
}
// The key set keeps its own long-lived client for JWKS refreshes.
keySet = oidc.NewRemoteKeySet(oidc.ClientContext(context.Background(), oidcHTTPClientFromProvider(provider)), meta.JWKSURL)
c.sets[issuer] = keySet
return keySet, nil
}
// OIDCBackChannelLogout terminates the local sessions identified by a signed
// OIDC back-channel logout token sent by the IdP (server-to-server, no cookie).
// Public route: POST /api/auth/oidc/:id/backchannel-logout with a
// `logout_token` form field. Returns 200 on success, 400 on an invalid token.
func (api *API) OIDCBackChannelLogout(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var logoutToken string
var keySet oidc.KeySet
var payload []byte
var claims oidcLogoutTokenClaims
var hasEvent bool
var now int64
var user models.User
var err error
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil || provider.Type != models.AuthProviderTypeOIDC {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return
}
if strings.TrimSpace(provider.OIDCIssuer) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "provider has no issuer configured for back-channel logout")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
err = r.ParseForm()
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid request body")
return
}
logoutToken = strings.TrimSpace(r.PostFormValue("logout_token"))
if logoutToken == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "missing logout_token")
return
}
keySet, err = backChannelKeySets.get(r.Context(), provider)
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc back-channel logout: jwks discovery failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "could not verify logout token")
return
}
payload, err = keySet.VerifySignature(r.Context(), logoutToken)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid logout token signature")
return
}
err = json.Unmarshal(payload, &claims)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid logout token")
return
}
// Claim validation per OIDC Back-Channel Logout 1.0 §2.6.
if strings.TrimSpace(claims.Iss) != strings.TrimSpace(provider.OIDCIssuer) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token issuer mismatch")
return
}
if strings.TrimSpace(provider.OIDCClientID) != "" && !oidcIDTokenAudienceContains(claims.Aud, provider.OIDCClientID) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token audience mismatch")
return
}
now = time.Now().Unix()
if claims.Iat == 0 || claims.Iat > now+60 || now-claims.Iat > 300 {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token iat out of range")
return
}
if claims.Exp != 0 && now > claims.Exp {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token expired")
return
}
_, hasEvent = claims.Events[oidcBackChannelLogoutEvent]
if !hasEvent {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token missing back-channel logout event")
return
}
// A logout token MUST NOT contain a nonce.
if strings.TrimSpace(claims.Nonce) != "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token must not contain a nonce")
return
}
if strings.TrimSpace(claims.Sub) == "" && strings.TrimSpace(claims.Sid) == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "logout token must contain sub or sid")
return
}
// Terminate sessions: sid targets the exact IdP session; sub falls back to
// all of the user's sessions. An unknown subject is not an error (idempotent).
if strings.TrimSpace(claims.Sid) != "" {
_, err = api.store(r).DeleteSessionsByProviderSid(provider.ID, strings.TrimSpace(claims.Sid))
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
} else {
user, err = api.store(r).GetUserByProviderAndSub(provider.ID, strings.TrimSpace(claims.Sub))
if err == nil {
_, err = api.store(r).DeleteSessionsByUserID(user.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
}
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc back-channel logout provider=%s sid=%q sub=%q", provider.Name, claims.Sid, claims.Sub)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
}
@@ -0,0 +1,77 @@
package handlers
import "net/http"
import "strings"
import "codit/internal/models"
import codit_logger "codit/logger"
// OIDCFrontChannelLogout terminates the local session(s) for an IdP-initiated
// front-channel logout. The IdP renders this URL in a hidden iframe in the
// user's browser at logout, passing `iss` and `sid` query params (OpenID
// Connect Front-Channel Logout 1.0). Unlike back-channel logout this request
// is unsigned and browser-mediated; we scope the logout by `sid` rather than
// the session cookie so it works even when the cross-site iframe cannot send
// codit's cookie (SameSite / third-party-cookie restrictions).
//
// Public route: GET /api/auth/oidc/:id/frontchannel-logout. Always returns 200
// (empty body) so the IdP's iframe never reports an error; enable per provider
// via oidc_frontchannel_logout_enabled and set frontchannel_logout_uri +
// frontchannel_logout_session_required=true on the IdP.
func (api *API) OIDCFrontChannelLogout(w http.ResponseWriter, r *http.Request, params map[string]string) {
var provider models.AuthProvider
var iss string
var sid string
var issuer string
var deleted int64
var err error
provider, err = api.store(r).GetAuthProvider(params["id"])
if err != nil || provider.Type != models.AuthProviderTypeOIDC || !provider.OIDCFrontChannelLogoutEnabled {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "oidc provider not found")
return
}
iss = strings.TrimSpace(r.URL.Query().Get("iss"))
sid = strings.TrimSpace(r.URL.Query().Get("sid"))
issuer = strings.TrimSpace(provider.OIDCIssuer)
// If the IdP sends an issuer, it must match the provider's configured one.
// A mismatch means the request is not for this provider; ignore it (still
// 200 so the iframe succeeds) rather than logging out the wrong session.
if iss != "" && issuer != "" && iss != issuer {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc front-channel logout: issuer mismatch provider=%s iss=%q", provider.Name, iss)
api.writeFrontChannelLogoutResponse(w)
return
}
// Scope strictly by sid: front-channel logout must set
// frontchannel_logout_session_required=true so the sid is present. Without
// it we cannot reliably identify the session (the cookie may be withheld),
// so treat it as a no-op instead of guessing.
if sid == "" {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc front-channel logout: missing sid provider=%s (set frontchannel_logout_session_required=true on the IdP)", provider.Name)
api.writeFrontChannelLogoutResponse(w)
return
}
deleted, err = api.store(r).DeleteSessionsByProviderSid(provider.ID, sid)
if err != nil {
// Even on error return 200 to the iframe; log for diagnosis.
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc front-channel logout: delete failed provider=%s sid=%q err=%v", provider.Name, sid, err)
api.writeFrontChannelLogoutResponse(w)
return
}
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc front-channel logout provider=%s sid=%q deleted=%d", provider.Name, sid, deleted)
api.writeFrontChannelLogoutResponse(w)
}
// writeFrontChannelLogoutResponse emits the minimal 200 the IdP's iframe
// expects. We deliberately set no X-Frame-Options / CSP frame-ancestors so the
// IdP can embed this endpoint, and no-store so the logout is never cached.
func (api *API) writeFrontChannelLogoutResponse(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
@@ -0,0 +1,293 @@
package handlers
import "context"
import "crypto/tls"
import "encoding/json"
import "io"
import "math/rand"
import "net/http"
import "net/url"
import "strconv"
import "strings"
import "sync"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import codit_logger "codit/logger"
// Cadence + load caps for the OIDC session-revalidation worker. The batch cap
// (per tick) and concurrency cap bound IdP load independent of session count;
// overflow is simply handled on later ticks, so detection latency degrades
// gracefully under load instead of hammering the IdP.
const revalTick time.Duration = 30 * time.Second
const revalNearExpirySkipSec int64 = 120
const revalThrottleCooldown time.Duration = 5 * time.Minute
const revalHTTPTimeout time.Duration = 15 * time.Second
// OIDCRevalidator periodically checks OIDC sessions against their IdP and deletes
// the ones whose IdP session has ended (outbound single-logout). Lifecycle mirrors
// notify.Relay: Start(ctx) / Stop() / Wait().
type OIDCRevalidator struct {
store *db.Store
logger codit_logger.Logger
stop chan struct{}
done chan struct{}
// throttledUntil pauses cycles after the IdP rate-limits us (429).
throttledUntil time.Time
}
func NewOIDCRevalidator(store *db.Store, logger codit_logger.Logger) *OIDCRevalidator {
return &OIDCRevalidator{store: store, logger: logger, stop: make(chan struct{}), done: make(chan struct{})}
}
func (rv *OIDCRevalidator) Start(ctx context.Context) {
go rv.run(ctx)
}
func (rv *OIDCRevalidator) Stop() {
select {
case <-rv.stop:
default:
close(rv.stop)
}
}
func (rv *OIDCRevalidator) Wait() {
<-rv.done
}
func (rv *OIDCRevalidator) log(level codit_logger.LogLevel, format string, args ...any) {
if rv.logger != nil {
rv.logger.Write("oidc-reval", level, format, args...)
}
}
func (rv *OIDCRevalidator) run(ctx context.Context) {
var ticker *time.Ticker
defer close(rv.done)
ticker = time.NewTicker(revalTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-rv.stop:
return
case <-ticker.C:
rv.cycle(ctx)
}
}
}
func (rv *OIDCRevalidator) cycle(ctx context.Context) {
var intervalSec int64
var now int64
var sessions []db.RevalidatableSession
var err error
if time.Now().Before(rv.throttledUntil) {
return
}
intervalSec = int64(rv.store.SettingDuration(db.SettingOIDCRevalidateIntervalSec).Seconds())
if intervalSec <= 0 {
return // disabled
}
// Load caps are admin settings (write-clamped to safe bounds by the catalog).
var batchMax int = int(rv.store.SettingInt(db.SettingOIDCRevalidateBatchMax))
var concurrency int = int(rv.store.SettingInt(db.SettingOIDCRevalidateConcurrency))
now = time.Now().Unix()
sessions, err = rv.store.ListRevalidatableSessions(now, revalNearExpirySkipSec, intervalSec, batchMax)
if err != nil {
rv.log(codit_logger.LOG_WARN, "list revalidatable sessions failed: %v", err)
return
}
if len(sessions) == 0 {
return
}
rv.checkBatch(ctx, sessions, concurrency)
}
// checkBatch runs the liveness checks with a bounded worker pool. A session that
// is confirmed dead is deleted; one that is alive (or hit a transient error) is
// stamped as revalidated (fail-open: a flaky IdP must not log everyone out).
func (rv *OIDCRevalidator) checkBatch(ctx context.Context, sessions []db.RevalidatableSession, concurrency int) {
var sem chan struct{}
var wg sync.WaitGroup
var mu sync.Mutex
var dead []string
var throttled bool
var i int
if concurrency < 1 {
concurrency = 1
}
sem = make(chan struct{}, concurrency)
for i = range sessions {
var s db.RevalidatableSession
s = sessions[i]
wg.Add(1)
sem <- struct{}{}
go func() {
var alive bool
var newRefresh string
var was429 bool
var checkErr error
defer wg.Done()
defer func() { <-sem }()
// Small jitter so a batch doesn't arrive at the IdP as one spike.
time.Sleep(time.Duration(rand.Intn(400)) * time.Millisecond)
alive, newRefresh, was429, checkErr = rv.checkOne(ctx, s)
mu.Lock()
defer mu.Unlock()
if was429 {
throttled = true
return
}
if checkErr != nil {
// Transient/network error: keep the session, pace the retry.
_ = rv.store.MarkSessionRevalidated(s.ID, time.Now().Unix(), s.RefreshToken)
return
}
if !alive {
dead = append(dead, s.ID)
return
}
if newRefresh == "" {
newRefresh = s.RefreshToken
}
_ = rv.store.MarkSessionRevalidated(s.ID, time.Now().Unix(), newRefresh)
}()
}
wg.Wait()
if throttled {
rv.throttledUntil = time.Now().Add(revalThrottleCooldown)
rv.log(codit_logger.LOG_WARN, "idp rate-limited revalidation; backing off %s", revalThrottleCooldown)
}
if len(dead) > 0 {
_, _ = rv.store.DeleteSessionsByIDs(ctx, dead)
rv.log(codit_logger.LOG_INFO, "revalidation ended %d session(s) (idp session gone)", len(dead))
}
}
// checkOne returns (alive, rotatedRefreshToken, throttled429, err). err is only
// for transient failures (network/5xx) — a definitive "not active" is alive=false.
func (rv *OIDCRevalidator) checkOne(ctx context.Context, s db.RevalidatableSession) (bool, string, bool, error) {
if s.Mode == models.OIDCRevalidationModeIntrospect {
return rv.introspect(ctx, s)
}
if s.Mode == models.OIDCRevalidationModeRefresh {
return rv.refreshAttempt(ctx, s)
}
return true, "", false, nil // unknown mode: treat as alive (no-op)
}
func revalHTTPClient(tlsSkip bool) *http.Client {
return &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: tlsSkip}, DisableKeepAlives: true},
Timeout: revalHTTPTimeout,
}
}
// introspect calls the RFC 7662 endpoint (read-only) with the refresh token.
func (rv *OIDCRevalidator) introspect(ctx context.Context, s db.RevalidatableSession) (bool, string, bool, error) {
var form url.Values
var req *http.Request
var res *http.Response
var body []byte
var parsed struct {
Active bool `json:"active"`
}
var err error
if strings.TrimSpace(s.IntrospectionURL) == "" {
return true, "", false, nil // misconfigured: don't log the user out
}
form = url.Values{}
form.Set("token", s.RefreshToken)
form.Set("token_type_hint", "refresh_token")
form.Set("client_id", s.ClientID)
form.Set("client_secret", s.ClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(s.IntrospectionURL), strings.NewReader(form.Encode()))
if err != nil {
return false, "", false, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err = revalHTTPClient(s.TLSInsecureSkipVerify).Do(req)
if err != nil {
return false, "", false, err
}
defer res.Body.Close()
body, _ = io.ReadAll(io.LimitReader(res.Body, 1<<16))
if res.StatusCode == http.StatusTooManyRequests {
return false, "", true, nil
}
if res.StatusCode >= 500 {
return false, "", false, errStatus(res.StatusCode)
}
if res.StatusCode != http.StatusOK {
// 4xx (bad client creds etc.): treat as transient — don't log users out.
return false, "", false, errStatus(res.StatusCode)
}
err = json.Unmarshal(body, &parsed)
if err != nil {
return false, "", false, err
}
return parsed.Active, "", false, nil
}
// refreshAttempt exchanges the refresh token at the token endpoint. Success means
// the session is alive (and rotates the refresh token); invalid_grant (400) means
// the IdP session was revoked.
func (rv *OIDCRevalidator) refreshAttempt(ctx context.Context, s db.RevalidatableSession) (bool, string, bool, error) {
var form url.Values
var req *http.Request
var res *http.Response
var body []byte
var parsed struct {
RefreshToken string `json:"refresh_token"`
}
var err error
if strings.TrimSpace(s.TokenURL) == "" {
return true, "", false, nil
}
form = url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", s.RefreshToken)
form.Set("client_id", s.ClientID)
form.Set("client_secret", s.ClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(s.TokenURL), strings.NewReader(form.Encode()))
if err != nil {
return false, "", false, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err = revalHTTPClient(s.TLSInsecureSkipVerify).Do(req)
if err != nil {
return false, "", false, err
}
defer res.Body.Close()
body, _ = io.ReadAll(io.LimitReader(res.Body, 1<<16))
if res.StatusCode == http.StatusTooManyRequests {
return false, "", true, nil
}
if res.StatusCode == http.StatusOK {
_ = json.Unmarshal(body, &parsed)
return true, strings.TrimSpace(parsed.RefreshToken), false, nil
}
if res.StatusCode == http.StatusBadRequest {
// invalid_grant → the refresh token/session is gone.
return false, "", false, nil
}
// Other statuses (5xx, auth errors): transient — keep the session.
return false, "", false, errStatus(res.StatusCode)
}
type statusError int
func (e statusError) Error() string { return "unexpected status " + strconv.Itoa(int(e)) }
func errStatus(code int) error { return statusError(code) }
+47 -3
View File
@@ -23,8 +23,9 @@ import "codit/internal/db"
import "codit/internal/models"
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
}
type oidcUserClaims struct {
@@ -209,7 +210,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required
tokenValue, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, token.IDToken)
tokenValue, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, token.IDToken, oidcSidFromIDToken(token.IDToken), token.RefreshToken)
if err != nil {
_ = txStore.Rollback()
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "session create failed user=%s err=%v", user.Username, err)
@@ -282,6 +283,16 @@ func clearOIDCStateCookieForProvider(w http.ResponseWriter, api *API, providerID
})
}
func oidcScopeContains(scopes string, target string) bool {
var field string
for _, field = range strings.Fields(scopes) {
if field == target {
return true
}
}
return false
}
func buildOIDCAuthorizeURLFromProvider(p models.AuthProvider, state string) string {
var values url.Values
var scopes string
@@ -294,6 +305,13 @@ func buildOIDCAuthorizeURLFromProvider(p models.AuthProvider, state string) stri
if scopes == "" {
scopes = "openid profile email"
}
// Session revalidation needs a refresh token; ensure offline_access is asked
// for (both introspect and refresh modes introspect/refresh the refresh token).
if p.OIDCRevalidationMode == models.OIDCRevalidationModeIntrospect || p.OIDCRevalidationMode == models.OIDCRevalidationModeRefresh {
if !oidcScopeContains(scopes, "offline_access") {
scopes = scopes + " offline_access"
}
}
values.Set("scope", scopes)
values.Set("state", state)
endpoint = p.OIDCAuthorizeURL
@@ -737,6 +755,32 @@ func oidcClaimsFromIDToken(idToken string, clientID string) (oidcUserClaims, err
return claims, nil
}
// oidcSidFromIDToken extracts the OIDC session id (`sid`) claim from an ID token,
// returning "" when absent. Captured at login so a back-channel logout token can
// terminate the exact session. The token was already validated during login;
// this only reads a claim from the (unverified) payload.
func oidcSidFromIDToken(idToken string) string {
var parts []string
var payload []byte
var err error
var raw struct {
Sid string `json:"sid"`
}
parts = strings.Split(idToken, ".")
if len(parts) < 2 {
return ""
}
payload, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
if json.Unmarshal(payload, &raw) != nil {
return ""
}
return strings.TrimSpace(raw.Sid)
}
// Username is a display artifact only; OIDC identity is looked up by (auth_provider_id, external_subject).
func oidcUsernameFromProviderAndSub(providerID string, sub string) string {
var sum [32]byte
+1 -1
View File
@@ -85,7 +85,7 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
if required && !user.TOTPEnabled {
sessionVerified = false
}
token, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, "")
token, expires, err = api.createSessionWithStore(txStore, user, sessionVerified, "", "", "")
if err != nil {
_ = txStore.Rollback()
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "session create failed user=%s err=%v", user.Username, err)
+5
View File
@@ -12,6 +12,11 @@ const AuthProviderTypeDB string = "db"
const AuthProviderTypeLDAP string = "ldap"
const AuthProviderTypeOIDC string = "oidc"
// OIDC session revalidation modes (outbound single-logout for a private RP).
const OIDCRevalidationModeOff string = "off"
const OIDCRevalidationModeIntrospect string = "introspect"
const OIDCRevalidationModeRefresh string = "refresh"
const GroupSyncModeOff string = "off"
const GroupSyncModeFirstLogin string = "first_login"
const GroupSyncModeSync string = "sync"
+99
View File
@@ -67,6 +67,10 @@ type AuthProvider struct {
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
OIDCEndSessionURL string `json:"oidc_end_session_url"`
OIDCPostLogoutRedirect bool `json:"oidc_post_logout_redirect"`
OIDCIssuer string `json:"oidc_issuer"`
OIDCRevalidationMode string `json:"oidc_revalidation_mode"`
OIDCIntrospectionURL string `json:"oidc_introspection_url"`
OIDCFrontChannelLogoutEnabled bool `json:"oidc_frontchannel_logout_enabled"`
GroupSyncMode string `json:"group_sync_mode"`
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
DefaultUserGroupID string `json:"default_user_group_id"`
@@ -273,6 +277,100 @@ type RPMMirrorStatus struct {
UpdatedAt int64 `json:"updated_at"`
}
type DockerMirrorConfig struct {
RepoID string `json:"repo_id"`
Image string `json:"image"`
RemoteURL string `json:"remote_url"`
Tags string `json:"tags"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
PKIClientCertID string `json:"pki_client_cert_id"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
SyncEnabled bool `json:"sync_enabled"`
Dirty bool `json:"dirty"`
NextSyncAt int64 `json:"next_sync_at"`
SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"`
SyncError string `json:"sync_error"`
SyncStep string `json:"sync_step"`
SyncTotal int64 `json:"sync_total"`
SyncDone int64 `json:"sync_done"`
SyncFailed int64 `json:"sync_failed"`
LastSyncStartedAt int64 `json:"last_sync_started_at"`
LastSyncFinishedAt int64 `json:"last_sync_finished_at"`
LastSyncSuccessAt int64 `json:"last_sync_success_at"`
LastSyncedRevision string `json:"last_synced_revision"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type DockerMirrorTask struct {
RepoID string `json:"repo_id"`
RepoPath string `json:"repo_path"`
Image string `json:"image"`
RemoteURL string `json:"remote_url"`
Tags string `json:"tags"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
PKIClientCertID string `json:"pki_client_cert_id"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
Dirty bool `json:"dirty"`
}
type DockerMirrorRun struct {
ID string `json:"id"`
RepoID string `json:"repo_id"`
Image string `json:"image"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at"`
Status string `json:"status"`
Step string `json:"step"`
Total int64 `json:"total"`
Done int64 `json:"done"`
Failed int64 `json:"failed"`
Revision string `json:"revision"`
Error string `json:"error"`
}
type DockerMirrorStatus struct {
ProjectID string `json:"project_id"`
ProjectSlug string `json:"project_slug"`
RepoID string `json:"repo_id"`
RepoName string `json:"repo_name"`
Image string `json:"image"`
RemoteURL string `json:"remote_url"`
Tags string `json:"tags"`
Username string `json:"username"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
PKIClientCertID string `json:"pki_client_cert_id"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
SyncEnabled bool `json:"sync_enabled"`
Dirty bool `json:"dirty"`
NextSyncAt int64 `json:"next_sync_at"`
SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"`
SyncError string `json:"sync_error"`
SyncStep string `json:"sync_step"`
SyncTotal int64 `json:"sync_total"`
SyncDone int64 `json:"sync_done"`
SyncFailed int64 `json:"sync_failed"`
LastSyncStartedAt int64 `json:"last_sync_started_at"`
LastSyncFinishedAt int64 `json:"last_sync_finished_at"`
LastSyncSuccessAt int64 `json:"last_sync_success_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Issue struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
@@ -922,6 +1020,7 @@ type BlockComment struct {
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CanDelete bool `json:"can_delete"`
CanEdit bool `json:"can_edit"`
DeleteAt int64 `json:"delete_at,omitempty"`
}
@@ -0,0 +1,8 @@
-- OIDC back-channel logout support.
-- The provider issuer identifies the IdP (validated against the logout token's
-- `iss` and used for OIDC discovery of the JWKS). The session sid records the
-- IdP session id (`sid`) captured from the ID token at login, so a back-channel
-- logout token scoped to a `sid` can terminate exactly the matching session(s).
ALTER TABLE auth_providers ADD COLUMN oidc_issuer TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN oidc_sid TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_sessions_oidc_sid ON sessions(oidc_sid) WHERE oidc_sid != '';
@@ -0,0 +1,11 @@
-- Outbound OIDC session revalidation (single-logout for a private-network RP the
-- IdP cannot reach). A background worker periodically checks each OIDC login
-- against its IdP; when the IdP session is gone, the local session is deleted.
-- oidc_revalidation_mode: '' / 'off' | 'introspect' (RFC 7662) | 'refresh' (refresh-attempt)
-- oidc_introspection_url: the RFC 7662 endpoint (used when mode = 'introspect')
-- Sessions keep the refresh token (the session-liveness signal) and the time they
-- were last revalidated (for due-based, load-bounded scheduling).
ALTER TABLE auth_providers ADD COLUMN oidc_revalidation_mode TEXT NOT NULL DEFAULT 'off';
ALTER TABLE auth_providers ADD COLUMN oidc_introspection_url TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN oidc_refresh_token TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN oidc_last_revalidated_at INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,9 @@
-- Indexes supporting OIDC single-logout queries.
-- * idx_sessions_oidc_reval backs the every-tick revalidation scan
-- (ListRevalidatableSessions): filter on OIDC sessions + ORDER BY last-checked.
-- * idx_sessions_user_id backs DeleteSessionsByUserID (sub-scoped logout) and
-- the session→user joins (SQLite does not auto-index foreign keys).
-- * idx_users_auth_provider_id backs the provider-scoped subqueries.
CREATE INDEX IF NOT EXISTS idx_sessions_oidc_reval ON sessions(oidc_last_revalidated_at) WHERE oidc_refresh_token != '';
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_users_auth_provider_id ON users(auth_provider_id);
@@ -0,0 +1,8 @@
-- OIDC front-channel logout support (per-provider opt-in).
-- Front-channel logout is browser-mediated: on an IdP-initiated logout the IdP
-- renders the RP's frontchannel_logout_uri in a hidden iframe, passing `iss`
-- and `sid` query params. codit terminates the matching session by `sid`
-- (cookie-independent, so it survives cross-site iframe cookie restrictions),
-- reusing the same oidc_sid capture and issuer validation as back-channel
-- logout. The flag gates whether a provider advertises/accepts the endpoint.
ALTER TABLE auth_providers ADD COLUMN oidc_frontchannel_logout_enabled INTEGER NOT NULL DEFAULT 0;
+48
View File
@@ -0,0 +1,48 @@
CREATE TABLE docker_mirrors (
repo_id INTEGER NOT NULL,
image TEXT NOT NULL DEFAULT '',
remote_url TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
sync_interval_sec INTEGER NOT NULL DEFAULT 300,
sync_enabled INTEGER NOT NULL DEFAULT 1,
dirty INTEGER NOT NULL DEFAULT 1,
next_sync_at INTEGER NOT NULL DEFAULT 0,
sync_running INTEGER NOT NULL DEFAULT 0,
sync_status TEXT NOT NULL DEFAULT 'idle',
sync_error TEXT NOT NULL DEFAULT '',
sync_step TEXT NOT NULL DEFAULT '',
sync_total INTEGER NOT NULL DEFAULT 0,
sync_done INTEGER NOT NULL DEFAULT 0,
sync_failed INTEGER NOT NULL DEFAULT 0,
last_sync_started_at INTEGER NOT NULL DEFAULT 0,
last_sync_finished_at INTEGER NOT NULL DEFAULT 0,
last_sync_success_at INTEGER NOT NULL DEFAULT 0,
last_synced_revision TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_id, image),
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
);
CREATE TABLE docker_mirror_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
repo_id INTEGER NOT NULL,
image TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
step TEXT NOT NULL DEFAULT '',
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
revision TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
FOREIGN KEY(repo_id, image) REFERENCES docker_mirrors(repo_id, image) ON DELETE CASCADE
);
CREATE INDEX idx_docker_mirrors_due ON docker_mirrors(sync_enabled, sync_running, next_sync_at, dirty, updated_at);
CREATE INDEX idx_docker_mirror_runs_repo_image_started ON docker_mirror_runs(repo_id, image, started_at DESC);
CREATE INDEX idx_docker_mirror_runs_status ON docker_mirror_runs(status);
@@ -0,0 +1,5 @@
ALTER TABLE docker_mirrors ADD COLUMN connect_host TEXT NOT NULL DEFAULT '';
ALTER TABLE docker_mirrors ADD COLUMN host_header TEXT NOT NULL DEFAULT '';
ALTER TABLE docker_mirrors ADD COLUMN tls_server_name TEXT NOT NULL DEFAULT '';
ALTER TABLE docker_mirrors ADD COLUMN tls_insecure_skip_verify INTEGER NOT NULL DEFAULT 0;
ALTER TABLE docker_mirrors ADD COLUMN pki_client_cert_id INTEGER REFERENCES pki_certs(id) ON DELETE SET NULL;
+25 -1
View File
@@ -163,8 +163,10 @@ type Server struct {
git_manager *git.RepoManager
rpm_meta *rpm.MetaManager
rpm_mirror *rpm.MirrorManager
docker_mirror *docker.MirrorManager
monitor_manager *monitor.Manager
notify_relay *notify.Relay
oidc_revalidator *handlers.OIDCRevalidator
upload_store *storage.FileStore
repo_locks *repolock.Manager
@@ -694,14 +696,16 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
app.rpm_meta.SetPostBuild(rpm.NewRepomdSigner(app.store, app.rpm_base_dir).SignDir)
}
app.rpm_mirror = rpm.NewMirrorManager(app.store, logger, app.rpm_meta)
app.repo_locks = repolock.NewManager()
app.docker_mirror = docker.NewMirrorManager(app.store, app.docker_base_dir, logger, app.repo_locks)
app.monitor_manager = monitor.NewManager(app.store, logger, monitor.DefaultEgressPolicy(), app.serverId)
app.monitor_manager.SetAuditRetentionDays(cfg.Audit.RetentionDays)
// External-delivery relay: drains the events outbox and dispatches each event
// to its matching notification channels (rules + per-monitor attachment). One
// path for every producer (monitoring, RPM mirror, …).
app.notify_relay = notify.NewRelay(app.store, notify.NewDispatcher(app.store, app.serverId, app.siteName, monitor.DefaultEgressPolicy(), cfg.DataDir, logger), logger)
app.oidc_revalidator = handlers.NewOIDCRevalidator(app.store, logger)
app.upload_store = &storage.FileStore{BaseDir: app.uploads_base_dir}
app.repo_locks = repolock.NewManager()
for _, dir = range []string{app.docker_base_dir, app.git_base_dir, app.rpm_base_dir, app.uploads_base_dir} {
err = os.MkdirAll(dir, storage.DirPerm)
@@ -738,11 +742,15 @@ func (app *Server) Close() {
// monitor) and waits for their goroutines to drain. Safe to call when either is
// nil or not started.
func (app *Server) stopBackgroundManagers() {
if app.oidc_revalidator != nil { app.oidc_revalidator.Stop() }
if app.monitor_manager != nil { app.monitor_manager.Stop() }
if app.docker_mirror != nil { app.docker_mirror.Stop() }
if app.rpm_mirror != nil { app.rpm_mirror.Stop() }
if app.notify_relay != nil { app.notify_relay.Stop() }
if app.oidc_revalidator != nil { app.oidc_revalidator.Wait() }
if app.monitor_manager != nil { app.monitor_manager.Wait() }
if app.docker_mirror != nil { app.docker_mirror.Wait() }
if app.rpm_mirror != nil { app.rpm_mirror.Wait() }
if app.notify_relay != nil { app.notify_relay.Wait() }
}
@@ -794,6 +802,12 @@ func (app *Server) ServeContext(ctx context.Context) error {
goto oops
}
err = app.docker_mirror.Start(ctx)
if err != nil {
app.logger.Write("", LOG_ERROR, "docker mirror manager error - %v", err)
goto oops
}
err = app.monitor_manager.Start(ctx)
if err != nil {
app.logger.Write("", LOG_ERROR, "monitor manager error - %v", err)
@@ -801,6 +815,7 @@ func (app *Server) ServeContext(ctx context.Context) error {
}
app.notify_relay.Start(ctx)
app.oidc_revalidator.Start(ctx)
err = extraListenerManager.Start()
@@ -861,6 +876,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
RpmBase: app.rpm_base_dir,
RpmMeta: app.rpm_meta,
RpmMirror: app.rpm_mirror,
DockerMirror: app.docker_mirror,
DockerBase: app.docker_base_dir,
Uploads: *app.upload_store,
Logger: logger,
@@ -901,6 +917,8 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/auth/providers", txRead(api.ListPublicAuthProviders))
router.Handle("GET", "/api/auth/oidc/:id/login", txRead(api.OIDCLoginWithProvider))
router.Handle("GET", "/api/auth/oidc/:id/callback", api.OIDCCallbackWithProvider)
router.Handle("POST", "/api/auth/oidc/:id/backchannel-logout", api.OIDCBackChannelLogout)
router.Handle("GET", "/api/auth/oidc/:id/frontchannel-logout", api.OIDCFrontChannelLogout)
router.Handle("GET", "/api/me", txRead(api.Me))
router.Handle("PATCH", "/api/me", api.UpdateMe)
router.Handle("POST", "/api/me/avatar", api.UploadMyAvatar)
@@ -1256,6 +1274,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/boards/:boardId/assignable-users", txRead(api.ListBoardAssignableUsers))
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/comments", txRead(api.ListBlockComments))
router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/comments", txWrite(api.CreateBlockComment))
router.Handle("PATCH", "/api/boards/:boardId/blocks/:blockId/comments/:commentId", txWrite(api.UpdateBlockComment))
router.Handle("DELETE", "/api/boards/:boardId/blocks/:blockId/comments/:commentId", txWrite(api.DeleteBlockComment))
router.Handle("GET", "/api/boards/:boardId/blocks/:blockId/checklist", txRead(api.ListBlockChecklistItems))
router.Handle("POST", "/api/boards/:boardId/blocks/:blockId/checklist", txWrite(api.CreateBlockChecklistItem))
@@ -1329,6 +1348,11 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/repos/:id/docker/images", api.RepoDockerImages)
router.Handle("GET", "/api/repos/:id/docker/tags", api.RepoDockerTags)
router.Handle("GET", "/api/repos/:id/docker/manifest", api.RepoDockerManifest)
router.Handle("GET", "/api/repos/:id/docker/mirrors", api.RepoDockerMirrors)
router.Handle("PUT", "/api/repos/:id/docker/mirrors", api.RepoDockerUpsertMirror)
router.Handle("DELETE", "/api/repos/:id/docker/mirrors", api.RepoDockerDeleteMirror)
router.Handle("POST", "/api/repos/:id/docker/mirrors/sync", api.RepoDockerSyncMirror)
router.Handle("GET", "/api/repos/:id/docker/mirrors/runs", api.RepoDockerMirrorRuns)
router.Handle("DELETE", "/api/repos/:id/docker/tag", api.RepoDockerDeleteTag)
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage)
router.Handle("POST", "/api/repos/:id/docker/tag/rename", api.RepoDockerRenameTag)
+90
View File
@@ -245,6 +245,67 @@ export interface DockerManifestDetail {
annotations?: Record<string, string>
}
export interface DockerMirrorStatus {
project_id: string
project_slug: string
repo_id: string
repo_name: string
image: string
remote_url: string
tags: string
username: string
connect_host: string
host_header: string
tls_server_name: string
tls_insecure_skip_verify: boolean
pki_client_cert_id: string
sync_interval_sec: number
sync_enabled: boolean
dirty: boolean
next_sync_at: number
sync_running: boolean
sync_status: string
sync_error: string
sync_step: string
sync_total: number
sync_done: number
sync_failed: number
last_sync_started_at: number
last_sync_finished_at: number
last_sync_success_at: number
updated_at: number
}
export interface DockerMirrorRun {
id: string
repo_id: string
image: string
started_at: number
finished_at: number
status: string
step: string
total: number
done: number
failed: number
revision: string
error: string
}
export interface DockerMirrorPayload {
image: string
remote_url: string
tags: string
username: string
password?: string
connect_host: string
host_header: string
tls_server_name: string
tls_insecure_skip_verify: boolean
pki_client_cert_id: string
sync_interval_sec: number
sync_enabled: boolean
}
export interface RpmTreeEntry {
name: string
path: string
@@ -606,6 +667,10 @@ export interface AuthProvider {
oidc_admission_expr: string
oidc_end_session_url: string
oidc_post_logout_redirect: boolean
oidc_issuer: string
oidc_revalidation_mode: 'off' | 'introspect' | 'refresh'
oidc_introspection_url: string
oidc_frontchannel_logout_enabled: boolean
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings?: AuthProviderGroupMapping[]
default_user_group_id: string
@@ -1658,6 +1723,7 @@ export interface BlockComment {
created_at: number
updated_at: number
can_delete?: boolean
can_edit?: boolean
}
export interface BlockChecklistItem {
@@ -2719,6 +2785,28 @@ export const api = {
if (image) params.set('image', image)
return request<DockerManifestDetail>(`/api/repos/${repoId}/docker/manifest?${params.toString()}`)
},
listDockerMirrors: (repoId: string) => request<DockerMirrorStatus[]>(`/api/repos/${repoId}/docker/mirrors`),
saveDockerMirror: (repoId: string, payload: DockerMirrorPayload) =>
request<DockerMirrorStatus>(`/api/repos/${repoId}/docker/mirrors`, {
method: 'PUT',
body: JSON.stringify(payload)
}),
deleteDockerMirror: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<{ status: string }>(`/api/repos/${repoId}/docker/mirrors?${params.toString()}`, { method: 'DELETE' })
},
syncDockerMirror: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<{ status: string }>(`/api/repos/${repoId}/docker/mirrors/sync?${params.toString()}`, { method: 'POST' })
},
listDockerMirrorRuns: (repoId: string, image: string, limit: number) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
params.set('limit', String(limit))
return request<DockerMirrorRun[]>(`/api/repos/${repoId}/docker/mirrors/runs?${params.toString()}`)
},
deleteDockerTag: (repoId: string, image: string, tag: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
@@ -2803,6 +2891,8 @@ export const api = {
request<BlockComment[]>(`/api/boards/${boardId}/blocks/${blockId}/comments`),
createBlockComment: (boardId: string, blockId: string, content: string) =>
request<BlockComment>(`/api/boards/${boardId}/blocks/${blockId}/comments`, { method: 'POST', body: JSON.stringify({ content }) }),
updateBlockComment: (boardId: string, blockId: string, commentId: string, content: string) =>
request<BlockComment>(`/api/boards/${boardId}/blocks/${blockId}/comments/${commentId}`, { method: 'PATCH', body: JSON.stringify({ content }) }),
deleteBlockComment: (boardId: string, blockId: string, commentId: string) =>
request<void>(`/api/boards/${boardId}/blocks/${blockId}/comments/${commentId}`, { method: 'DELETE' }),
listBlockChecklistItems: (boardId: string, blockId: string) =>
+2 -2
View File
@@ -50,7 +50,7 @@ const RepoDetailPage = lazy(() => import('../pages/RepoDetailPage'))
const RepoGitBranchesPage = lazy(() => import('../pages/RepoGitBranchesPage'))
const RepoGitCommitsPage = lazy(() => import('../pages/RepoGitCommitsPage'))
const RepoGitCommitDetailPage = lazy(() => import('../pages/RepoGitCommitDetailPage'))
const RepoRpmMirrorPage = lazy(() => import('../pages/RepoRpmMirrorPage'))
const RepoMirrorPage = lazy(() => import('../pages/RepoMirrorPage'))
const IssuesPage = lazy(() => import('../pages/IssuesPage'))
const WikiPage = lazy(() => import('../pages/WikiPage'))
const FilesPage = lazy(() => import('../pages/FilesPage'))
@@ -114,7 +114,7 @@ export const routes: RouteObject[] = [
{ path: 'projects/:projectId/repos/:repoId/branches', element: withRouteSuspense(<RepoGitBranchesPage />) },
{ path: 'projects/:projectId/repos/:repoId/commits', element: withRouteSuspense(<RepoGitCommitsPage />) },
{ path: 'projects/:projectId/repos/:repoId/commits/:hash', element: withRouteSuspense(<RepoGitCommitDetailPage />) },
{ path: 'projects/:projectId/repos/:repoId/mirrors', element: withRouteSuspense(<RepoRpmMirrorPage />) },
{ path: 'projects/:projectId/repos/:repoId/mirrors', element: withRouteSuspense(<RepoMirrorPage />) },
{ path: 'projects/:projectId/boards', element: withRouteSuspense(<ProjectBoardsPage />) },
{ path: 'projects/:projectId/boards/:boardId', element: withRouteSuspense(<BoardPage />) },
{ path: 'projects/:projectId/issues', element: withRouteSuspense(<IssuesPage />) },
@@ -41,6 +41,10 @@ export type AuthProviderFormState = {
oidc_admission_expr: string
oidc_end_session_url: string
oidc_post_logout_redirect: boolean
oidc_issuer: string
oidc_revalidation_mode: 'off' | 'introspect' | 'refresh'
oidc_introspection_url: string
oidc_frontchannel_logout_enabled: boolean
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings: AuthProviderGroupMapping[]
default_user_group_id: string
@@ -82,6 +86,10 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
oidc_admission_expr: '',
oidc_end_session_url: '',
oidc_post_logout_redirect: false,
oidc_issuer: '',
oidc_revalidation_mode: 'off',
oidc_introspection_url: '',
oidc_frontchannel_logout_enabled: false,
group_sync_mode: 'off',
group_mappings: [],
default_user_group_id: ''
@@ -111,6 +119,10 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
oidc_admission_expr: item.oidc_admission_expr || '',
oidc_end_session_url: item.oidc_end_session_url || '',
oidc_post_logout_redirect: item.oidc_post_logout_redirect,
oidc_issuer: item.oidc_issuer || '',
oidc_revalidation_mode: (item.oidc_revalidation_mode as 'off' | 'introspect' | 'refresh') || 'off',
oidc_introspection_url: item.oidc_introspection_url || '',
oidc_frontchannel_logout_enabled: item.oidc_frontchannel_logout_enabled,
group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off',
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
claim_value: mapping.claim_value || '',
@@ -305,6 +317,12 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
<TextField label="Authorize URL" value={props.form.oidc_authorize_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))} />
<TextField label="Token URL" value={props.form.oidc_token_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_token_url: event.target.value }))} />
<TextField label="UserInfo URL (optional)" value={props.form.oidc_userinfo_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))} />
<TextField
label="Issuer (optional)"
value={props.form.oidc_issuer}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_issuer: event.target.value }))}
helperText={props.editingProvider ? `Required for back-channel logout (OIDC discovery of the JWKS). Endpoint: /api/auth/oidc/${props.editingProvider.id}/backchannel-logout` : 'Issuer URL (e.g. https://idp.example.com/realms/acme). Required only for back-channel logout.'}
/>
<TextField
label="Redirect URL"
value={props.form.oidc_redirect_url}
@@ -384,6 +402,45 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
<Typography variant="caption" color="text.secondary">
When enabled, logout appends post_logout_redirect_uri back to the login page. The ID token is also sent as id_token_hint when available.
</Typography>
<FormControlLabel
control={
<Checkbox
checked={props.form.oidc_frontchannel_logout_enabled}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_frontchannel_logout_enabled: event.target.checked }))}
/>
}
label="Accept front-channel logout"
/>
<Typography variant="caption" color="text.secondary">
{props.editingProvider
? `Lets the IdP sign the user out here via a browser iframe on IdP-initiated logout. On the IdP, set frontchannel_logout_uri to /api/auth/oidc/${props.editingProvider.id}/frontchannel-logout and frontchannel_logout_session_required=true. Requires the Issuer above.`
: 'Lets the IdP sign the user out here via a browser iframe on IdP-initiated logout. Configure the frontchannel_logout_uri (shown after creating the provider) and set frontchannel_logout_session_required=true on the IdP. Requires the Issuer above.'}
</Typography>
</Box>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Session revalidation (single sign-out)</Typography>
<SelectField
select
label="Revalidation mode"
value={props.form.oidc_revalidation_mode}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_revalidation_mode: event.target.value as 'off' | 'introspect' | 'refresh' }))}
helperText="Periodically re-check logins against the IdP so a sign-out elsewhere signs the user out here too. Interval is set on the Settings page."
>
<MenuItem value="off">Off</MenuItem>
<MenuItem value="introspect">Introspection (RFC 7662)</MenuItem>
<MenuItem value="refresh">Refresh attempt</MenuItem>
</SelectField>
{props.form.oidc_revalidation_mode === 'introspect' ? (
<TextField
label="Introspection URL"
value={props.form.oidc_introspection_url}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_introspection_url: event.target.value }))}
helperText="Token introspection endpoint (e.g. Keycloak …/token/introspect). Not offered by Microsoft Entra — use Refresh attempt there."
/>
) : null}
<Typography variant="caption" color="text.secondary">
Both modes need a refresh token; offline_access is requested automatically when this is enabled.
</Typography>
</Box>
</>
) : null}
@@ -4,7 +4,8 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Box, Checkbox, IconButton, TextField, Typography } from '@mui/material'
import { Box, Checkbox, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { useState } from 'react'
import { BlockChecklistItem } from '../api'
type CardChecklistItemRowProps = {
@@ -23,6 +24,7 @@ type CardChecklistItemRowProps = {
export default function CardChecklistItemRow(props: CardChecklistItemRowProps) {
const { item, editing, editingTitle, saving, onToggle, onStartEdit, onEditingTitleChange, onSaveEdit, onCancelEdit, onDelete } = props
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
const [confirmingDelete, setConfirmingDelete] = useState(false)
return (
<Box
@@ -82,7 +84,7 @@ export default function CardChecklistItemRow(props: CardChecklistItemRowProps) {
) : (
<Typography
variant="body2"
onClick={() => onStartEdit(item)}
onClick={() => { setConfirmingDelete(false); onStartEdit(item) }}
sx={{
flex: 1,
minWidth: 0,
@@ -95,9 +97,25 @@ export default function CardChecklistItemRow(props: CardChecklistItemRowProps) {
</Typography>
)}
{editing ? null : (
<IconButton size="small" onClick={() => onDelete(item.id)}>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
confirmingDelete ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<Typography variant="caption" color="error">Delete?</Typography>
<Tooltip title="Confirm delete">
<IconButton size="small" color="error" onClick={() => { setConfirmingDelete(false); onDelete(item.id) }}>
<CheckIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Cancel">
<IconButton size="small" onClick={() => setConfirmingDelete(false)}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
) : (
<IconButton size="small" onClick={() => setConfirmingDelete(true)}>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
)
)}
</Box>
)
+116 -12
View File
@@ -78,6 +78,10 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false)
const [commentError, setCommentError] = useState<string | null>(null)
const [editingCommentId, setEditingCommentId] = useState<string | null>(null)
const [editingCommentContent, setEditingCommentContent] = useState('')
const [savingComment, setSavingComment] = useState(false)
const [confirmDeleteCommentId, setConfirmDeleteCommentId] = useState<string | null>(null)
const [checklistItems, setChecklistItems] = useState<BlockChecklistItem[]>([])
const [loadingChecklist, setLoadingChecklist] = useState(false)
const [newChecklistTitle, setNewChecklistTitle] = useState('')
@@ -129,6 +133,9 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
setDeleteError(null)
setConfirmDelete(false)
setNewComment('')
setEditingCommentId(null)
setEditingCommentContent('')
setConfirmDeleteCommentId(null)
setNewChecklistTitle('')
setEditingChecklistItemId(null)
setEditingChecklistTitle('')
@@ -280,6 +287,33 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
api.deleteBlockComment(boardId, block.id, commentId)
.then(() => setComments((prev) => prev.filter((c) => c.id !== commentId)))
.catch((err: Error) => setCommentError(err.message))
.finally(() => setConfirmDeleteCommentId(null))
}
function startEditComment(comment: BlockComment) {
setCommentError(null)
setEditingCommentId(comment.id)
setEditingCommentContent(comment.content)
}
function cancelEditComment() {
setEditingCommentId(null)
setEditingCommentContent('')
}
function saveEditComment(commentId: string) {
if (!block) return
const trimmed: string = editingCommentContent.trim()
if (!trimmed) return
setSavingComment(true)
setCommentError(null)
api.updateBlockComment(boardId, block.id, commentId, trimmed)
.then((updated) => {
setComments((prev) => prev.map((c) => (c.id === commentId ? updated : c)))
cancelEditComment()
})
.catch((err: Error) => setCommentError(err.message))
.finally(() => setSavingComment(false))
}
function handleCreateChecklistItem() {
@@ -792,21 +826,91 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
</Typography>
<Typography variant="caption" color="text.secondary">
{relativeTime(c.created_at)}
{c.updated_at > c.created_at ? ' · edited' : ''}
</Typography>
{c.can_delete ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }}
onClick={() => handleDeleteComment(c.id)}
>
Delete
</Typography>
{editingCommentId !== c.id && (c.can_edit || c.can_delete) ? (
<Box sx={{ ml: 'auto', display: 'flex', gap: 1.5, alignItems: 'baseline' }}>
{confirmDeleteCommentId === c.id ? (
<>
<Typography variant="caption" color="error">Delete?</Typography>
<Typography
variant="caption"
sx={{ cursor: 'pointer', color: 'error.main', fontWeight: 500 }}
onClick={() => handleDeleteComment(c.id)}
>
Yes
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ cursor: 'pointer', '&:hover': { color: 'text.primary' } }}
onClick={() => setConfirmDeleteCommentId(null)}
>
Cancel
</Typography>
</>
) : (
<>
{c.can_edit ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
onClick={() => startEditComment(c)}
>
Edit
</Typography>
) : null}
{c.can_delete ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ cursor: 'pointer', '&:hover': { color: 'error.main' } }}
onClick={() => setConfirmDeleteCommentId(c.id)}
>
Delete
</Typography>
) : null}
</>
)}
</Box>
) : null}
</Box>
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
{c.content}
</Typography>
{editingCommentId === c.id ? (
<Box sx={{ mt: 0.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
value={editingCommentContent}
onChange={(e) => setEditingCommentContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveEditComment(c.id)
if (e.key === 'Escape') cancelEditComment()
}}
multiline
minRows={2}
fullWidth
size="small"
autoFocus
disabled={savingComment}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
size="small"
onClick={() => saveEditComment(c.id)}
disabled={!editingCommentContent.trim() || savingComment}
>
Save
</Button>
<Button size="small" onClick={cancelEditComment} disabled={savingComment}>
Cancel
</Button>
</Box>
</Box>
) : (
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
{c.content}
</Typography>
)}
</Box>
</Box>
))}
@@ -0,0 +1,228 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import FormControlLabel from '@mui/material/FormControlLabel'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { MonitorClientCert } from '../api'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
export type DockerMirrorFormState = {
image: string
remote_url: string
tags: string
username: string
password: string
connect_host: string
host_header: string
tls_server_name: string
tls_insecure_skip_verify: boolean
pki_client_cert_id: string
sync_interval_sec: string
sync_enabled: boolean
}
type DockerMirrorFormDialogProps = {
open: boolean
title: string
form: DockerMirrorFormState
error: string | null
busy: boolean
editing: boolean
clientCerts: MonitorClientCert[]
setForm: React.Dispatch<React.SetStateAction<DockerMirrorFormState>>
onClose: () => void
onSave: () => void
}
export default function DockerMirrorFormDialog(props: DockerMirrorFormDialogProps) {
const saveDisabled: boolean = props.busy || !props.form.image.trim() || !props.form.remote_url.trim()
const remoteURL: string = props.form.remote_url.trim().toLowerCase()
const isHTTPRemote: boolean = remoteURL.startsWith('http://')
const selectedClientCert: MonitorClientCert | null = props.clientCerts.find((item: MonitorClientCert) => item.id === props.form.pki_client_cert_id) || (props.form.pki_client_cert_id ? {
id: props.form.pki_client_cert_id,
common_name: props.form.pki_client_cert_id,
ca_name: 'Unavailable',
not_after: 0
} : null)
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{props.title}</Typography>
{props.error ? <Alert severity="error">{props.error}</Alert> : null}
</Box>
</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mirror Target
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) minmax(0, 1fr)' }, gap: 1 }}>
<TextField
size="small"
label="Local image"
value={props.form.image}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, image: event.target.value }))}
placeholder="team/api"
disabled={props.editing}
helperText={props.editing ? 'The local image path identifies the mirror and cannot be changed.' : 'Required; root image mirroring is disabled.'}
fullWidth
/>
<TextField
size="small"
label="Remote image URL"
value={props.form.remote_url}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => {
const nextRemoteURL: string = event.target.value
const nextIsHTTP: boolean = nextRemoteURL.trim().toLowerCase().startsWith('http://')
if (nextIsHTTP) {
return { ...prev, remote_url: nextRemoteURL, tls_server_name: '', tls_insecure_skip_verify: false, pki_client_cert_id: '' }
}
return { ...prev, remote_url: nextRemoteURL }
})}
placeholder="registry-1.docker.io/library/nginx"
helperText="Use https:// for normal registries or http:// for an insecure registry."
fullWidth
/>
</Box>
<TextField
size="small"
label="Tags"
value={props.form.tags}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, tags: event.target.value }))}
placeholder="latest,1.27"
helperText="Comma-separated tags. Leave blank to mirror all tags reported by the remote registry."
fullWidth
/>
</Box>
<Divider />
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
Authentication and Schedule
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) minmax(0, 1fr)' }, gap: 1 }}>
<TextField
size="small"
label="Username"
value={props.form.username}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, username: event.target.value }))}
fullWidth
/>
<TextField
size="small"
type="password"
label="Password/token"
value={props.form.password}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, password: event.target.value }))}
helperText={props.editing ? 'Leave blank to keep the existing secret.' : undefined}
fullWidth
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '180px auto' }, gap: 1, alignItems: 'center' }}>
<TextField
size="small"
label="Sync interval"
value={props.form.sync_interval_sec}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, sync_interval_sec: event.target.value }))}
helperText="Seconds"
fullWidth
/>
<FormControlLabel
control={<Switch checked={props.form.sync_enabled} onChange={(_event, checked: boolean) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, sync_enabled: checked }))} />}
label="Enabled"
sx={{ m: 0 }}
/>
</Box>
</Box>
<Divider />
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
Transport and TLS
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) minmax(0, 1fr)' }, gap: 1 }}>
<TextField
size="small"
label="Connect host"
value={props.form.connect_host}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, connect_host: event.target.value }))}
placeholder="10.0.0.10:5000"
helperText="Optional host:port to dial instead of the URL host."
fullWidth
/>
<TextField
size="small"
label="Host header"
value={props.form.host_header}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, host_header: event.target.value }))}
placeholder="registry.example.com"
helperText="Optional HTTP Host value for virtual hosts."
fullWidth
/>
<TextField
size="small"
label="TLS server name"
value={props.form.tls_server_name}
onChange={(event) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, tls_server_name: event.target.value }))}
placeholder="registry.example.com"
helperText={isHTTPRemote ? 'TLS options are disabled for http:// mirrors.' : 'Optional SNI/certificate name override.'}
disabled={isHTTPRemote}
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', minHeight: 40 }}>
<FormControlLabel
control={<Switch checked={props.form.tls_insecure_skip_verify} onChange={(_event, checked: boolean) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, tls_insecure_skip_verify: checked }))} disabled={isHTTPRemote} />}
label="Skip TLS certificate verification"
sx={{ m: 0 }}
/>
</Box>
</Box>
{props.clientCerts.length > 0 || props.form.pki_client_cert_id ? (
<Autocomplete<MonitorClientCert, false, false, false>
options={props.clientCerts}
value={selectedClientCert}
onChange={(_event, value: MonitorClientCert | null) => props.setForm((prev: DockerMirrorFormState) => ({ ...prev, pki_client_cert_id: value?.id || '' }))}
getOptionLabel={(item: MonitorClientCert) => `${item.common_name} (${item.ca_name})`}
isOptionEqualToValue={(option: MonitorClientCert, value: MonitorClientCert) => option.id === value.id}
size="small"
disabled={isHTTPRemote}
renderInput={(params) => (
<TextField
{...params}
label="Client certificate (mTLS)"
helperText={
isHTTPRemote
? 'mTLS requires an https:// mirror.'
: props.form.pki_client_cert_id && !props.clientCerts.some((item: MonitorClientCert) => item.id === props.form.pki_client_cert_id)
? 'Selected certificate is unavailable; pick another or clear it.'
: 'Optional PKI client certificate presented to registries requiring mTLS.'
}
/>
)}
/>
) : null}
</Box>
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={saveDisabled}>
Save
</Button>
<Button onClick={props.onClose} disabled={props.busy}>
Cancel
</Button>
</DialogActions>
</Dialog>
)
}
+6 -2
View File
@@ -1,5 +1,5 @@
import ViewKanbanOutlinedIcon from '@mui/icons-material/ViewKanbanOutlined'
import { Box, List, ListItemButton, Typography } from '@mui/material'
import { Box, Link, List, ListItemButton, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, Board } from '../api'
@@ -26,7 +26,11 @@ export default function ProjectBoardList({ projectId }: { projectId: string }) {
}, [projectId])
return (
<SectionCard collapsible title={<Typography variant="h6">Boards ({boards.length})</Typography>}>
<SectionCard collapsible title={
<Link onClick={() => navigate(`/projects/${projectId}/boards`) } sx={{ cursor: 'pointer' }}>
<Typography variant="h6">Boards ({boards.length})</Typography>
</Link>
}>
{error ? (
<Typography variant="body2" color="error">{error}</Typography>
) : loading ? (
+6 -2
View File
@@ -1,4 +1,4 @@
import { Box, List, ListItemButton, Tooltip, Typography } from '@mui/material'
import { Box, Link, List, ListItemButton, Tooltip, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api, Repo } from '../api'
@@ -29,7 +29,11 @@ export default function ProjectRepoList({ projectId }: { projectId: string }) {
}, [projectId])
return (
<SectionCard collapsible title={<Typography variant="h6">Repositories ({repos.length})</Typography>}>
<SectionCard collapsible title={
<Link onClick={() => navigate(`/projects/${projectId}/repos`) } sx={{ cursor: 'pointer' }}>
<Typography variant="h6">Repositories ({repos.length})</Typography>
</Link>
}>
{error ? (
<Typography variant="body2" color="error">{error}</Typography>
) : loading ? (
+5
View File
@@ -54,6 +54,11 @@ export default function RepoSubNav(props: RepoSubNavProps) {
path: base,
active: currentPath === basePath,
},
{
label: 'Mirrors',
path: `${base}/mirrors`,
active: currentPath.startsWith(`${basePath}/mirrors`),
},
] : [
{
label: 'Code',
+425
View File
@@ -0,0 +1,425 @@
import { Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
import RefreshIcon from '@mui/icons-material/Refresh'
import SyncIcon from '@mui/icons-material/Sync'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import AddIcon from '@mui/icons-material/Add'
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, DockerMirrorPayload, DockerMirrorRun, DockerMirrorStatus, MonitorClientCert, Project, Repo } from '../api'
import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame'
import RepoForeignChip from '../components/RepoForeignChip'
import RepoSubNav from '../components/RepoSubNav'
import SectionCard from '../components/SectionCard'
import PageAlert from '../components/PageAlert'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import DockerMirrorFormDialog from '../components/DockerMirrorFormDialog'
import { DockerMirrorFormState } from '../components/DockerMirrorFormDialog'
import { useRepoEditAction } from '../components/useRepoEditAction'
import { repoContextAccessError } from '../repoAccess'
import { formatEpochTime } from '../time'
const emptyForm: DockerMirrorFormState = {
image: '',
remote_url: '',
tags: '',
username: '',
password: '',
connect_host: '',
host_header: '',
tls_server_name: '',
tls_insecure_skip_verify: false,
pki_client_cert_id: '',
sync_interval_sec: '300',
sync_enabled: true,
}
function statusColor(item: DockerMirrorStatus): 'default' | 'success' | 'warning' | 'error' {
if (item.sync_running) return 'warning'
if (item.sync_status === 'failed' || item.sync_error) return 'error'
if (!item.sync_enabled) return 'default'
if (item.sync_status === 'success') return 'success'
return 'warning'
}
function statusLabel(item: DockerMirrorStatus): string {
if (item.sync_running) return 'running'
if (!item.sync_enabled) return 'suspended'
if (item.sync_status === 'success') return 'healthy'
if (item.sync_status === 'failed') return 'failed'
if (item.dirty) return 'pending'
return item.sync_status || 'idle'
}
function fmt(value: number): string {
if (!value || value <= 0) return '-'
return formatEpochTime(value)
}
function mirrorDetail(item: DockerMirrorStatus): string {
if (item.sync_running) return `Progress ${item.sync_done}/${item.sync_total || 0} · failed ${item.sync_failed}`
if (item.sync_error) return item.sync_error
if (item.last_sync_finished_at > 0) return `Last sync ${fmt(item.last_sync_finished_at)} · next ${fmt(item.next_sync_at)}`
if (item.next_sync_at > 0) return `Next sync ${fmt(item.next_sync_at)}`
return 'No sync yet'
}
function mirrorTitle(item: DockerMirrorStatus): string {
return item.image || '(root)'
}
type MetricBoxProps = {
label: string
value: string
helper?: string
}
function MetricBox(props: MetricBoxProps) {
return (
<Box
sx={{
display: 'grid',
gap: 0.25,
p: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: 1,
minWidth: 0
}}
>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{props.label}
</Typography>
<Typography variant="h6" sx={{ lineHeight: 1.1 }}>{props.value}</Typography>
{props.helper ? <Typography variant="caption" color="text.secondary">{props.helper}</Typography> : null}
</Box>
)
}
type RepoDockerMirrorPageProps = {
initialRepo?: Repo
}
export default function RepoDockerMirrorPage(props: RepoDockerMirrorPageProps) {
const { projectId, repoId } = useParams()
const { initialRepo } = props
const [project, setProject] = useState<Project | null>(null)
const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [items, setItems] = useState<DockerMirrorStatus[]>([])
const [runs, setRuns] = useState<Record<string, DockerMirrorRun[]>>({})
const [clientCerts, setClientCerts] = useState<MonitorClientCert[]>([])
const [form, setForm] = useState<DockerMirrorFormState>(emptyForm)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<DockerMirrorStatus | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<DockerMirrorStatus | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const repoEdit = useRepoEditAction({ projectId, repo, onUpdated: setRepo })
const repoAccessError: string | null = repoContextAccessError(projectId, repo)
const runningMirrors: DockerMirrorStatus[] = items.filter((item: DockerMirrorStatus) => item.sync_running)
const failedMirrors: DockerMirrorStatus[] = items.filter((item: DockerMirrorStatus) => item.sync_status === 'failed' || Boolean(item.sync_error))
const suspendedMirrors: DockerMirrorStatus[] = items.filter((item: DockerMirrorStatus) => !item.sync_enabled)
const pendingMirrors: DockerMirrorStatus[] = items.filter((item: DockerMirrorStatus) => item.sync_enabled && !item.sync_running && item.dirty)
useEffect(() => {
if (!projectId) return
api.getProject(projectId).then((data: Project) => setProject(data)).catch(() => setProject(null))
}, [projectId])
useEffect(() => {
if (!repoId) return
if (initialRepo && initialRepo.id === repoId) {
const message: string | null = repoContextAccessError(projectId, initialRepo)
if (message) setError(message)
setRepo(initialRepo)
return
}
api.getRepo(repoId)
.then((data: Repo) => {
const message: string | null = repoContextAccessError(projectId, data)
if (message) setError(message)
setRepo(data)
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Failed to load repository')
setRepo(null)
})
}, [repoId, projectId, initialRepo])
const loadMirrors = async () => {
let list: DockerMirrorStatus[]
let runList: DockerMirrorRun[]
let nextRuns: Record<string, DockerMirrorRun[]>
let item: DockerMirrorStatus
if (!repoId || repoAccessError) return
setLoading(true)
setError(null)
try {
list = await api.listDockerMirrors(repoId)
setItems(Array.isArray(list) ? list : [])
nextRuns = {}
for (item of list) {
runList = await api.listDockerMirrorRuns(repoId, item.image, 5)
nextRuns[item.image] = Array.isArray(runList) ? runList : []
}
setRuns(nextRuns)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Docker mirrors')
setItems([])
setRuns({})
} finally {
setLoading(false)
}
}
useEffect(() => {
loadMirrors().catch(() => {})
}, [repoId, repoAccessError])
useEffect(() => {
api.listMonitorClientCerts().then((data: MonitorClientCert[]) => setClientCerts(Array.isArray(data) ? data : [])).catch(() => setClientCerts([]))
}, [])
const beginAddMirror = () => {
setForm(emptyForm)
setEditingItem(null)
setFormError(null)
setFormOpen(true)
}
const cancelEdit = () => {
setForm(emptyForm)
setEditingItem(null)
setFormError(null)
setFormOpen(false)
}
const editMirror = (item: DockerMirrorStatus) => {
setForm({
image: item.image,
remote_url: item.remote_url,
tags: item.tags || '',
username: item.username || '',
password: '',
connect_host: item.connect_host || '',
host_header: item.host_header || '',
tls_server_name: item.tls_server_name || '',
tls_insecure_skip_verify: Boolean(item.tls_insecure_skip_verify),
pki_client_cert_id: item.pki_client_cert_id || '',
sync_interval_sec: String(item.sync_interval_sec || 300),
sync_enabled: item.sync_enabled,
})
setEditingItem(item)
setFormError(null)
setFormOpen(true)
}
const saveMirror = async () => {
let payload: DockerMirrorPayload
let interval: number
if (!repoId) return
interval = Number.parseInt(form.sync_interval_sec, 10)
if (!Number.isFinite(interval) || interval <= 0) interval = 300
payload = {
image: form.image.trim().replace(/^\/+|\/+$/g, ''),
remote_url: form.remote_url.trim(),
tags: form.tags.trim(),
username: form.username.trim(),
password: form.password,
connect_host: form.connect_host.trim(),
host_header: form.host_header.trim(),
tls_server_name: form.tls_server_name.trim(),
tls_insecure_skip_verify: form.tls_insecure_skip_verify,
pki_client_cert_id: form.pki_client_cert_id.trim(),
sync_interval_sec: interval,
sync_enabled: form.sync_enabled,
}
if (!payload.image) {
setFormError('Local image is required.')
return
}
setSaving(true)
setFormError(null)
try {
await api.saveDockerMirror(repoId, payload)
setForm(emptyForm)
setEditingItem(null)
setFormOpen(false)
await loadMirrors()
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Failed to save Docker mirror')
} finally {
setSaving(false)
}
}
const syncMirror = async (image: string) => {
if (!repoId) return
setError(null)
try {
await api.syncDockerMirror(repoId, image)
await loadMirrors()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to queue Docker mirror')
}
}
const beginDeleteMirror = (item: DockerMirrorStatus) => {
setDeleteTarget(item)
setDeleteConfirm('')
setDeleteError(null)
}
const deleteMirror = async () => {
let image: string
if (!repoId) return
if (!deleteTarget) return
image = deleteTarget.image
if (deleteConfirm.trim() !== (image || 'root')) {
setDeleteError('Type the mirror image name to confirm deletion.')
return
}
setDeleteError(null)
setDeleting(true)
try {
await api.deleteDockerMirror(repoId, image)
setDeleteTarget(null)
setDeleteConfirm('')
await loadMirrors()
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete Docker mirror')
} finally {
setDeleting(false)
}
}
return (
<ProjectPageFrame
projectId={projectId ?? ''}
project={project}
parentItems={[
{ label: 'Repositories', to: `/projects/${projectId}/repos` },
{ label: repo?.name || 'Repository', to: `/projects/${projectId}/repos/${repoId}` },
]}
currentLabel="Mirrors"
contextToolbar={
<ContextToolbar
kind="Repository"
label={repo?.name || 'Repository'}
labelAdornment={<RepoForeignChip projectId={projectId} repo={repo} />}
nav={projectId && repoId && repo?.type ? <RepoSubNav projectId={projectId} repoId={repoId} repoType={repo.type} inline /> : null}
actions={repoEdit.action}
/>
}
>
<Divider sx={{ mb: 1 }} />
<PageAlert severity="warning" message={error} onClose={() => setError(null)} />
<SectionCard
title="Docker Mirrors"
subtitle="Pull remote registry tags into this Docker repository"
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Button size="small" variant="outlined" startIcon={<RefreshIcon />} onClick={loadMirrors} disabled={loading}>Refresh</Button>
<Button size="small" variant="contained" startIcon={<AddIcon />} onClick={beginAddMirror}>Add Mirror</Button>
</Box>
}
>
{repo && repo.type !== 'docker' ? (
<Typography variant="body2" color="text.secondary">Mirror status is available for Docker repositories only.</Typography>
) : (
<Box sx={{ display: 'grid', gap: 1.25 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' }, gap: 1 }}>
<MetricBox label="Mirrors" value={String(items.length)} />
<MetricBox label="Running" value={String(runningMirrors.length)} helper={`${failedMirrors.length} failed`} />
<MetricBox label="Suspended" value={String(suspendedMirrors.length)} />
<MetricBox label="Pending" value={String(pendingMirrors.length)} helper="dirty and enabled" />
</Box>
<Divider />
{loading ? <Typography variant="body2" color="text.secondary">Loading mirror status...</Typography> : null}
{!loading && items.length === 0 ? (
<Typography variant="body2" color="text.secondary">No Docker mirrors configured.</Typography>
) : (
<Box sx={{ display: 'grid', gap: 0 }}>
{items.map((item: DockerMirrorStatus) => (
<Box
key={item.image}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`
}}
>
<Box sx={{ minWidth: 0, display: 'grid', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, flexWrap: 'wrap' }}>
<Chip size="small" variant="outlined" color={statusColor(item)} label={statusLabel(item)} />
<Typography variant="body2" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{mirrorTitle(item)}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Remote: {item.remote_url}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{mirrorDetail(item)}
</Typography>
{runs[item.image]?.length ? (
<Typography variant="caption" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Recent: {runs[item.image].map((run: DockerMirrorRun) => `${run.status} ${run.done}/${run.total}`).join(' · ')}
</Typography>
) : null}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<Tooltip title="Sync now"><span><Button size="small" startIcon={<SyncIcon />} disabled={item.sync_running} onClick={() => syncMirror(item.image)}>Sync</Button></span></Tooltip>
<Tooltip title="Edit"><Button size="small" onClick={() => editMirror(item)}>Edit</Button></Tooltip>
<Tooltip title="Delete mirror"><Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => beginDeleteMirror(item)}>Delete</Button></Tooltip>
</Box>
</Box>
))}
</Box>
)}
</Box>
)}
</SectionCard>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
title="Delete mirror"
prompt="Type the mirror image name to confirm deletion."
expectedText={deleteTarget?.image || 'root'}
confirmLabel="Type image name to confirm"
confirmValue={deleteConfirm}
onConfirmValueChange={setDeleteConfirm}
error={deleteError}
busy={deleting}
onConfirm={deleteMirror}
onClose={() => setDeleteTarget(null)}
>
<Typography variant="body2">
Delete Docker mirror "{deleteTarget?.image || 'root'}"?
</Typography>
</ConfirmDeleteDialog>
<DockerMirrorFormDialog
open={formOpen}
title={editingItem ? 'Edit Docker Mirror' : 'Add Docker Mirror'}
form={form}
error={formError}
busy={saving}
editing={Boolean(editingItem)}
clientCerts={clientCerts}
setForm={setForm}
onClose={cancelEdit}
onSave={saveMirror}
/>
{repoEdit.dialog}
</ProjectPageFrame>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, Repo } from '../api'
import RepoDockerMirrorPage from './RepoDockerMirrorPage'
import RepoRpmMirrorPage from './RepoRpmMirrorPage'
import PageAlert from '../components/PageAlert'
export default function RepoMirrorPage() {
const { repoId } = useParams()
const [repo, setRepo] = useState<Repo | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!repoId) return
setError(null)
api.getRepo(repoId)
.then((data: Repo) => setRepo(data))
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Failed to load repository')
setRepo(null)
})
}, [repoId])
if (error) return <PageAlert severity="warning" message={error} onClose={() => setError(null)} />
if (!repo) return null
if (repo.type === 'docker') return <RepoDockerMirrorPage initialRepo={repo} />
return <RepoRpmMirrorPage initialRepo={repo} />
}
+15 -3
View File
@@ -12,11 +12,16 @@ import SectionCard from '../components/SectionCard'
import { useRepoEditAction } from '../components/useRepoEditAction'
import { repoContextAccessError } from '../repoAccess'
export default function RepoRpmMirrorPage() {
type RepoRpmMirrorPageProps = {
initialRepo?: Repo
}
export default function RepoRpmMirrorPage(props: RepoRpmMirrorPageProps) {
const { projectId, repoId } = useParams()
const { initialRepo } = props
const [searchParams] = useSearchParams()
const [project, setProject] = useState<Project | null>(null)
const [repo, setRepo] = useState<Repo | null>(null)
const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [mirrors, setMirrors] = useState<RpmMirrorStatus[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
@@ -47,6 +52,12 @@ export default function RepoRpmMirrorPage() {
useEffect(() => {
if (!repoId) return
if (initialRepo && initialRepo.id === repoId) {
const message: string | null = repoContextAccessError(projectId, initialRepo)
if (message) setLoadError(message)
setRepo(initialRepo)
return
}
api
.getRepo(repoId)
.then((data: Repo) => {
@@ -63,7 +74,7 @@ export default function RepoRpmMirrorPage() {
setLoadError(message)
setRepo(null)
})
}, [repoId, projectId])
}, [repoId, projectId, initialRepo])
const loadMirrors = async () => {
if (!repoId || !repo) return
@@ -135,6 +146,7 @@ export default function RepoRpmMirrorPage() {
</Box>
)}
</SectionCard>
{repoEdit.dialog}
</ProjectPageFrame>
)
}