Compare commits
3 Commits
986172b6fc
...
2d4c85ed5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4c85ed5e | |||
| 74c012cd07 | |||
| 557b35035c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}, ", ")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -25,6 +25,7 @@ import "codit/internal/models"
|
||||
type oidcTokenResponse struct {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)}>
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
{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={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }}
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
sx={{ cursor: 'pointer', '&:hover': { color: 'error.main' } }}
|
||||
onClick={() => setConfirmDeleteCommentId(c.id)}
|
||||
>
|
||||
Delete
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user