mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段; Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
This commit is contained in:
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
// downloadPagesPackageFromURL is the deprecated one-shot URL adapter. It uses
|
||||
// the same bounded downloader as persisted sources, with the legacy trusted
|
||||
// network policy that permits operator-managed internal artifact services.
|
||||
// the same bounded downloader as persisted sources and allows insecure TLS for
|
||||
// operator-managed internal artifact services.
|
||||
func downloadPagesPackageFromURL(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
@@ -25,7 +25,7 @@ func downloadPagesPackageFromURL(
|
||||
}
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: strings.TrimSpace(rawURL),
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -41,9 +41,7 @@ const (
|
||||
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLMode = "remote_url_set 与 remote_url 参数不匹配"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceNetworkPolicy = "远程地址网络策略仅支持 public 或 trusted_internal"
|
||||
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
|
||||
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
|
||||
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
|
||||
|
||||
@@ -53,8 +53,7 @@ func validateGitHubSourceInput(input SourceUpdateInput) error {
|
||||
if strings.TrimSpace(input.SourceType) != PagesSourceTypeGitHubRelease {
|
||||
return errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
if input.RemoteURLSet || strings.TrimSpace(input.RemoteURL) != "" ||
|
||||
strings.TrimSpace(input.RemoteNetworkPolicy) != "" {
|
||||
if strings.TrimSpace(input.RemoteURL) != "" || input.AllowInsecure {
|
||||
return errors.New(errPagesSourceGitHubFields)
|
||||
}
|
||||
if _, err := normalizeGitHubRepositoryURL(input.RepositoryURL); err != nil {
|
||||
@@ -275,7 +274,7 @@ func githubSourceUpdates(config githubSourceConfig, version int) map[string]any
|
||||
return map[string]any{
|
||||
"source_type": PagesSourceTypeGitHubRelease,
|
||||
"remote_url": "",
|
||||
"remote_network_policy": "",
|
||||
"allow_insecure": false,
|
||||
"github_repository": config.Repository,
|
||||
"release_selector": config.Selector,
|
||||
"release_tag": config.Tag,
|
||||
@@ -289,7 +288,7 @@ func githubSourceUpdates(config githubSourceConfig, version int) map[string]any
|
||||
|
||||
func githubSourceConfigChanged(existing *model.PagesProjectSource, config githubSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeGitHubRelease || existing.RemoteURL != "" ||
|
||||
existing.RemoteNetworkPolicy != "" || existing.GitHubRepository != config.Repository ||
|
||||
existing.AllowInsecure || existing.GitHubRepository != config.Repository ||
|
||||
existing.ReleaseSelector != config.Selector || existing.ReleaseTag != config.Tag ||
|
||||
existing.AssetName != config.AssetName || existing.AutoUpdateEnabled != config.AutoUpdate ||
|
||||
existing.CheckIntervalMinutes != config.CheckInterval
|
||||
|
||||
@@ -150,9 +150,8 @@ func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
secret := "provider-switch-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
||||
AllowInsecure: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
||||
}
|
||||
@@ -165,8 +164,8 @@ func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
||||
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
|
||||
}
|
||||
github, _ := mustLoadPagesSource(t, ctx, project.ID)
|
||||
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
|
||||
if github.RemoteURL != "" || github.AllowInsecure {
|
||||
t.Errorf("GitHub switched source retained Remote fields: URL=%q allow_insecure=%v", github.RemoteURL, github.AllowInsecure)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ func handleSourceLogicError(c *gin.Context, err error) bool {
|
||||
errPagesSourceTypeUnsupported,
|
||||
errPagesSourceRemoteFields,
|
||||
errPagesSourceRemoteURLRequired,
|
||||
errPagesSourceRemoteURLMode,
|
||||
errPagesSourceRemoteURLInvalid,
|
||||
errPagesSourceNetworkPolicy,
|
||||
errPagesSourceGitHubFields,
|
||||
errPagesSourceRepositoryInvalid,
|
||||
errPagesSourceSelectorInvalid,
|
||||
@@ -239,7 +237,7 @@ func DeleteProjectHandler(c *gin.Context) {
|
||||
|
||||
// GetSourceHandler 获取 Pages 项目的部署源。
|
||||
// @Summary 获取 Pages 部署源
|
||||
// @Description 返回脱敏后的项目部署源配置与运行状态,需要管理员权限
|
||||
// @Description 返回项目部署源配置与运行状态,需要管理员权限
|
||||
// @Tags openflare-pages
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
|
||||
ctx,
|
||||
remoteProject.ID,
|
||||
"https://example.com/site.zip?token=handler-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
code, envelope = performPagesSourceRequest(
|
||||
t,
|
||||
@@ -135,7 +135,7 @@ func TestPagesSourceHandlersReturnStableActionErrors(t *testing.T) {
|
||||
ctx,
|
||||
busyProject.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
future := time.Now().Add(time.Minute)
|
||||
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
|
||||
@@ -172,7 +172,7 @@ func TestSyncSourceHandlerAcceptsEmptyBodyAndEmptyObject(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=dispatch-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
path := fmt.Sprintf("/api/v1/d/pages/%d/source/sync", project.ID)
|
||||
|
||||
|
||||
@@ -45,12 +45,11 @@ const (
|
||||
|
||||
// SourceUpdateInput is the discriminated source configuration payload.
|
||||
// GitHub fields are accepted by the decoder so mode-incompatible values can be
|
||||
// rejected deterministically; GitHub itself is enabled in Phase 2.
|
||||
// rejected deterministically.
|
||||
type SourceUpdateInput struct {
|
||||
SourceType string `json:"source_type"`
|
||||
RemoteURLSet bool `json:"remote_url_set"`
|
||||
RemoteURL string `json:"remote_url"`
|
||||
RemoteNetworkPolicy string `json:"remote_network_policy"`
|
||||
AllowInsecure bool `json:"allow_insecure"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
ReleaseSelector string `json:"release_selector"`
|
||||
ReleaseTag string `json:"release_tag"`
|
||||
@@ -59,19 +58,18 @@ type SourceUpdateInput struct {
|
||||
CheckIntervalMinutes int `json:"check_interval_minutes"`
|
||||
}
|
||||
|
||||
// SourceRevisionView is a credential-free source cursor.
|
||||
// SourceRevisionView is a source cursor shown to the console.
|
||||
type SourceRevisionView struct {
|
||||
Revision string `json:"revision"`
|
||||
Label string `json:"label"`
|
||||
AssetName string `json:"asset_name,omitempty"`
|
||||
}
|
||||
|
||||
// SourceView is the safe discriminated source view returned to the console.
|
||||
// SourceView is the discriminated source view returned to the console.
|
||||
type SourceView struct {
|
||||
SourceType string `json:"source_type"`
|
||||
HasRemoteURL bool `json:"has_remote_url,omitempty"`
|
||||
DisplayURL string `json:"display_url,omitempty"`
|
||||
RemoteNetworkPolicy string `json:"remote_network_policy,omitempty"`
|
||||
RemoteURL string `json:"remote_url,omitempty"`
|
||||
AllowInsecure bool `json:"allow_insecure,omitempty"`
|
||||
GitHubRepository string `json:"github_repository,omitempty"`
|
||||
ReleaseSelector string `json:"release_selector,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
@@ -115,9 +113,9 @@ type sourceDetail struct {
|
||||
}
|
||||
|
||||
type remoteSourceConfig struct {
|
||||
URL string
|
||||
Policy string
|
||||
Identity string
|
||||
URL string
|
||||
AllowInsecure bool
|
||||
Identity string
|
||||
}
|
||||
|
||||
// GetSource returns the current persisted source or a manual discriminator.
|
||||
@@ -228,26 +226,22 @@ func loadProjectSourceForUpdate(tx *gorm.DB, projectID uint) (*model.PagesProjec
|
||||
}
|
||||
|
||||
func buildRemoteSourceConfig(
|
||||
existing *model.PagesProjectSource,
|
||||
hasExisting bool,
|
||||
_ *model.PagesProjectSource,
|
||||
_ bool,
|
||||
input SourceUpdateInput,
|
||||
) (remoteSourceConfig, error) {
|
||||
remoteURL, err := resolveUpdatedRemoteURL(existing, hasExisting, input)
|
||||
if err != nil {
|
||||
return remoteSourceConfig{}, err
|
||||
remoteURL := strings.TrimSpace(input.RemoteURL)
|
||||
if remoteURL == "" {
|
||||
return remoteSourceConfig{}, errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
parsedURL, err := parseRemoteSourceURL(remoteURL)
|
||||
if err != nil {
|
||||
return remoteSourceConfig{}, err
|
||||
}
|
||||
policy := strings.TrimSpace(input.RemoteNetworkPolicy)
|
||||
if policy == "" {
|
||||
policy = RemoteNetworkPolicyPublic
|
||||
}
|
||||
return remoteSourceConfig{
|
||||
URL: remoteURL,
|
||||
Policy: policy,
|
||||
Identity: remoteSourceIdentity(parsedURL),
|
||||
URL: remoteURL,
|
||||
AllowInsecure: input.AllowInsecure,
|
||||
Identity: remoteSourceIdentity(parsedURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -256,7 +250,7 @@ func createRemoteSourceTx(tx *gorm.DB, projectID uint, config remoteSourceConfig
|
||||
ProjectID: projectID,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: config.URL,
|
||||
RemoteNetworkPolicy: config.Policy,
|
||||
AllowInsecure: config.AllowInsecure,
|
||||
AutoUpdateEnabled: false,
|
||||
CheckIntervalMinutes: 0,
|
||||
ConfigVersion: 1,
|
||||
@@ -289,7 +283,7 @@ func updateExistingRemoteSourceTx(
|
||||
if err := tx.Model(existing).Updates(map[string]any{
|
||||
"source_type": PagesSourceTypeRemoteURL,
|
||||
"remote_url": config.URL,
|
||||
"remote_network_policy": config.Policy,
|
||||
"allow_insecure": config.AllowInsecure,
|
||||
"github_repository": "",
|
||||
"release_selector": "",
|
||||
"release_tag": "",
|
||||
@@ -307,7 +301,7 @@ func updateExistingRemoteSourceTx(
|
||||
func remoteSourceConfigChanged(existing *model.PagesProjectSource, config remoteSourceConfig) bool {
|
||||
return existing.SourceType != PagesSourceTypeRemoteURL ||
|
||||
existing.RemoteURL != config.URL ||
|
||||
existing.RemoteNetworkPolicy != config.Policy ||
|
||||
existing.AllowInsecure != config.AllowInsecure ||
|
||||
existing.GitHubRepository != "" ||
|
||||
existing.ReleaseSelector != "" ||
|
||||
existing.ReleaseTag != "" ||
|
||||
@@ -363,14 +357,7 @@ func validateRemoteSourceInput(input SourceUpdateInput) error {
|
||||
input.AutoUpdateEnabled || input.CheckIntervalMinutes != 0 {
|
||||
return errors.New(errPagesSourceRemoteFields)
|
||||
}
|
||||
policy := strings.TrimSpace(input.RemoteNetworkPolicy)
|
||||
if policy != "" && policy != RemoteNetworkPolicyPublic && policy != RemoteNetworkPolicyTrustedInternal {
|
||||
return errors.New(errPagesSourceNetworkPolicy)
|
||||
}
|
||||
if !input.RemoteURLSet && strings.TrimSpace(input.RemoteURL) != "" {
|
||||
return errors.New(errPagesSourceRemoteURLMode)
|
||||
}
|
||||
if input.RemoteURLSet && strings.TrimSpace(input.RemoteURL) == "" {
|
||||
if strings.TrimSpace(input.RemoteURL) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
return nil
|
||||
@@ -389,16 +376,6 @@ func validateSourceUpdateInput(input SourceUpdateInput) error {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveUpdatedRemoteURL(existing *model.PagesProjectSource, hasExisting bool, input SourceUpdateInput) (string, error) {
|
||||
if input.RemoteURLSet {
|
||||
return strings.TrimSpace(input.RemoteURL), nil
|
||||
}
|
||||
if !hasExisting || existing.SourceType != PagesSourceTypeRemoteURL || strings.TrimSpace(existing.RemoteURL) == "" {
|
||||
return "", errors.New(errPagesSourceRemoteURLRequired)
|
||||
}
|
||||
return existing.RemoteURL, nil
|
||||
}
|
||||
|
||||
func parseRemoteSourceURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
||||
@@ -436,20 +413,6 @@ func remoteSourceIdentity(parsed *url.URL) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func displayRemoteSourceURL(raw string) string {
|
||||
parsed, err := parseRemoteSourceURL(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
hadQuery := parsed.RawQuery != ""
|
||||
parsed.RawQuery = ""
|
||||
display := parsed.String()
|
||||
if hadQuery {
|
||||
display += "?***"
|
||||
}
|
||||
return display
|
||||
}
|
||||
|
||||
func loadSourceByProject(ctx context.Context, projectID uint) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime, error) {
|
||||
var source model.PagesProjectSource
|
||||
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
||||
@@ -477,9 +440,8 @@ func buildSourceView(source *model.PagesProjectSource, runtime *model.PagesProje
|
||||
}
|
||||
switch source.SourceType {
|
||||
case PagesSourceTypeRemoteURL:
|
||||
view.HasRemoteURL = strings.TrimSpace(source.RemoteURL) != ""
|
||||
view.DisplayURL = displayRemoteSourceURL(source.RemoteURL)
|
||||
view.RemoteNetworkPolicy = source.RemoteNetworkPolicy
|
||||
view.RemoteURL = source.RemoteURL
|
||||
view.AllowInsecure = source.AllowInsecure
|
||||
case PagesSourceTypeGitHubRelease:
|
||||
view.LastCheckedAt = runtime.LastCheckedAt
|
||||
view.NextCheckAt = runtime.NextCheckAt
|
||||
|
||||
@@ -26,13 +26,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// RemoteNetworkPolicyPublic only permits publicly routable targets and
|
||||
// performs DNS validation again for every connection.
|
||||
RemoteNetworkPolicyPublic = "public"
|
||||
// RemoteNetworkPolicyTrustedInternal permits private targets and self-signed
|
||||
// TLS certificates. It is an explicit administrator trust boundary.
|
||||
RemoteNetworkPolicyTrustedInternal = "trusted_internal"
|
||||
|
||||
remoteSourceDownloadTimeout = 10 * time.Minute
|
||||
remoteSourceResponseHeaderTimeout = 30 * time.Second
|
||||
remoteSourceDialTimeout = 30 * time.Second
|
||||
@@ -52,12 +45,9 @@ func (providerError remoteProviderError) Error() string {
|
||||
return string(providerError)
|
||||
}
|
||||
|
||||
const (
|
||||
errRemoteProviderInvalidPolicy remoteProviderError = "远程来源网络策略无效"
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderBlockedAddress remoteProviderError = "远程来源 public 策略禁止访问非公网地址"
|
||||
errRemoteProviderResolveFailed remoteProviderError = "远程来源地址解析失败"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
const (
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed
|
||||
errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge
|
||||
errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty
|
||||
@@ -65,38 +55,10 @@ const (
|
||||
errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败"
|
||||
)
|
||||
|
||||
var remoteSourceNonPublicPrefixes = []netip.Prefix{
|
||||
// IPv4 special-use, private, link-local, documentation, multicast and
|
||||
// reserved ranges. A conservative deny list is intentional for SSRF safety.
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
// IPv6 protocol-assignment, documentation and transition ranges that are
|
||||
// not acceptable as direct public artifact origins.
|
||||
netip.MustParsePrefix("2001::/23"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("3fff::/20"),
|
||||
}
|
||||
|
||||
var remoteSourcePublicIPv6Prefix = netip.MustParsePrefix("2000::/3")
|
||||
|
||||
// RemoteSourceRequest describes one immutable Remote URL package fetch.
|
||||
type RemoteSourceRequest struct {
|
||||
URL string
|
||||
NetworkPolicy string
|
||||
AllowInsecure bool
|
||||
MaxPackageBytes int64
|
||||
}
|
||||
|
||||
@@ -158,20 +120,16 @@ func fetchRemoteSource(ctx context.Context, request RemoteSourceRequest, depende
|
||||
if dependencies.dialContext == nil || dependencies.createTemp == nil {
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
policy, err := normalizeRemoteNetworkPolicy(request.NetworkPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := parseRemoteSourceURL(request.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRemoteSourceTarget(ctx, parsed, policy, dependencies.resolver); err != nil {
|
||||
if err := validateRemoteSourceTarget(ctx, parsed); err != nil {
|
||||
return nil, sanitizeRemoteProviderError(ctx, err)
|
||||
}
|
||||
|
||||
safeLabel, namedFormat := remoteSourceLabel(parsed)
|
||||
client := newRemoteSourceClient(policy, dependencies)
|
||||
client := newRemoteSourceClient(request.AllowInsecure, dependencies)
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := requestRemoteSource(ctx, client, parsed)
|
||||
if err != nil {
|
||||
@@ -211,33 +169,18 @@ func fetchRemoteSource(ctx context.Context, request RemoteSourceRequest, depende
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRemoteNetworkPolicy(policy string) (string, error) {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "", RemoteNetworkPolicyPublic:
|
||||
return RemoteNetworkPolicyPublic, nil
|
||||
case RemoteNetworkPolicyTrustedInternal:
|
||||
return RemoteNetworkPolicyTrustedInternal, nil
|
||||
default:
|
||||
return "", errRemoteProviderInvalidPolicy
|
||||
}
|
||||
}
|
||||
|
||||
func newRemoteSourceClient(policy string, dependencies remoteSourceDependencies) *http.Client {
|
||||
func newRemoteSourceClient(allowInsecure bool, dependencies remoteSourceDependencies) *http.Client {
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
dialContext := dependencies.dialContext
|
||||
if policy == RemoteNetworkPolicyPublic {
|
||||
dialContext = newPublicRemoteSourceDialer(dependencies.resolver, dependencies.dialContext)
|
||||
} else {
|
||||
// trusted_internal is an explicit administrator-selected boundary for
|
||||
// private artifact services using an internal CA or self-signed cert.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // required trusted_internal semantics
|
||||
if allowInsecure {
|
||||
// Explicit administrator choice for self-signed or private CA endpoints.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // required allow_insecure semantics
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: remoteSourceDownloadTimeout,
|
||||
Transport: httppool.NewTransport(httppool.TransportOptions{
|
||||
Proxy: nil,
|
||||
DialContext: dialContext,
|
||||
DialContext: dependencies.dialContext,
|
||||
TLSClientConfig: tlsConfig,
|
||||
ResponseHeaderTimeout: remoteSourceResponseHeaderTimeout,
|
||||
TraceFilter: remoteSourceTraceFilter,
|
||||
@@ -248,7 +191,7 @@ func newRemoteSourceClient(policy string, dependencies remoteSourceDependencies)
|
||||
return errRemoteProviderRedirectLimit
|
||||
}
|
||||
stripRemoteSourceRedirectHeaders(next)
|
||||
if err := validateRemoteSourceTarget(next.Context(), next.URL, policy, dependencies.resolver); err != nil {
|
||||
if err := validateRemoteSourceTarget(next.Context(), next.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
applyRemoteSourceHeaders(next)
|
||||
@@ -293,7 +236,7 @@ func remoteSourceTraceFilter(request *http.Request) bool {
|
||||
return request.URL == nil || request.URL.RawQuery == ""
|
||||
}
|
||||
|
||||
func validateRemoteSourceTarget(ctx context.Context, target *url.URL, policy string, resolver remoteSourceResolver) error {
|
||||
func validateRemoteSourceTarget(_ context.Context, target *url.URL) error {
|
||||
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
@@ -301,92 +244,7 @@ func validateRemoteSourceTarget(ctx context.Context, target *url.URL, policy str
|
||||
if (scheme != remoteSourceSchemeHTTP && scheme != remoteSourceSchemeHTTPS) || strings.TrimSpace(target.Hostname()) == "" {
|
||||
return errors.New(errPagesSourceRemoteURLInvalid)
|
||||
}
|
||||
if policy != RemoteNetworkPolicyPublic {
|
||||
return nil
|
||||
}
|
||||
_, err := resolvePublicRemoteSourceIPs(ctx, resolver, target.Hostname())
|
||||
return err
|
||||
}
|
||||
|
||||
func newPublicRemoteSourceDialer(
|
||||
resolver remoteSourceResolver,
|
||||
directDial func(context.Context, string, string) (net.Conn, error),
|
||||
) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
addresses, err := resolvePublicRemoteSourceIPs(ctx, resolver, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !remoteSourceIPMatchesNetwork(address, network) {
|
||||
continue
|
||||
}
|
||||
connection, dialErr := directDial(ctx, network, net.JoinHostPort(address.String(), port))
|
||||
if dialErr == nil {
|
||||
return connection, nil
|
||||
}
|
||||
}
|
||||
return nil, errRemoteProviderDownloadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublicRemoteSourceIPs(ctx context.Context, resolver remoteSourceResolver, host string) ([]netip.Addr, error) {
|
||||
if strings.Contains(host, "%") {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
if literal, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if !isPublicRemoteSourceIP(literal) {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
if resolver == nil {
|
||||
return nil, errRemoteProviderResolveFailed
|
||||
}
|
||||
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return nil, errRemoteProviderResolveFailed
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !isPublicRemoteSourceIP(address) {
|
||||
return nil, errRemoteProviderBlockedAddress
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func isPublicRemoteSourceIP(address netip.Addr) bool {
|
||||
if !address.IsValid() || address.Zone() != "" {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
if !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
if address.Is6() && !remoteSourcePublicIPv6Prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range remoteSourceNonPublicPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func remoteSourceIPMatchesNetwork(address netip.Addr, network string) bool {
|
||||
switch network {
|
||||
case "tcp4":
|
||||
return address.Unmap().Is4()
|
||||
case "tcp6":
|
||||
return address.Unmap().Is6()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamRemoteSourcePackage(
|
||||
@@ -531,10 +389,7 @@ func sanitizeRemoteProviderError(ctx context.Context, err error) error {
|
||||
return fmt.Errorf("%w: %w", errRemoteProviderDownloadFailed, ctxErr)
|
||||
}
|
||||
for _, safeError := range []error{
|
||||
errRemoteProviderInvalidPolicy,
|
||||
errRemoteProviderInvalidLimit,
|
||||
errRemoteProviderBlockedAddress,
|
||||
errRemoteProviderResolveFailed,
|
||||
errRemoteProviderRedirectLimit,
|
||||
errRemoteProviderTooLarge,
|
||||
errRemoteProviderEmpty,
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestFetchRemoteSourceTrustedInternalSelfSignedAndSafeLabel(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=source-secret",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -106,7 +106,7 @@ func TestFetchRemoteSourceKeepsOriginalLabelAcrossRedirect(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/original/site.zip?token=initial-secret",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -118,29 +118,28 @@ func TestFetchRemoteSourceKeepsOriginalLabelAcrossRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsNonPublicAddresses(t *testing.T) {
|
||||
tests := []string{
|
||||
"http://127.0.0.1/site.zip?token=loopback-secret",
|
||||
"http://[::1]/site.zip?token=ipv6-secret",
|
||||
"http://100.64.0.1/site.zip?token=cgnat-secret",
|
||||
"http://192.0.2.1/site.zip?token=documentation-secret",
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateAddresses(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip?token=private-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
for _, rawURL := range tests {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
_, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
})
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("FetchRemoteSource() error = %v, want blocked address", err)
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "secret", "token=")
|
||||
})
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicDialsValidatedIP(t *testing.T) {
|
||||
func TestFetchRemoteSourcePublicUsesDirectDialer(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
@@ -156,76 +155,44 @@ func TestFetchRemoteSourcePublicDialsValidatedIP(t *testing.T) {
|
||||
}
|
||||
candidate, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/site.zip",
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchRemoteSource() error = %v", err)
|
||||
}
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if dialedAddress != "93.184.216.34:80" {
|
||||
t.Fatalf("direct dial address = %q, want validated IP", dialedAddress)
|
||||
if dialedAddress != "artifact.example:80" {
|
||||
t.Fatalf("direct dial address = %q, want hostname dial", dialedAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsPrivateRedirect(t *testing.T) {
|
||||
var requestCount atomic.Int32
|
||||
func TestFetchRemoteSourcePublicAllowsPrivateRedirect(t *testing.T) {
|
||||
packageBytes := makeRemoteSourceZIP(t)
|
||||
var privateServer *httptest.Server
|
||||
privateServer = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write(packageBytes)
|
||||
}))
|
||||
t.Cleanup(privateServer.Close)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
requestCount.Add(1)
|
||||
writer.Header().Set("Location", "http://127.0.0.1/private.zip?token=redirect-secret")
|
||||
writer.Header().Set("Location", privateServer.URL+"/private.zip?token=redirect-secret")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
rawURL := "http://artifact.example/start.zip?token=initial-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want blocked redirect", err)
|
||||
}
|
||||
if requestCount.Load() != 1 {
|
||||
t.Fatalf("request count = %d, private redirect must not be requested", requestCount.Load())
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "initial-secret", "redirect-secret", "token=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsDNSRebinding(t *testing.T) {
|
||||
var lookupCount atomic.Int32
|
||||
resolver := remoteSourceResolverFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
if lookupCount.Add(1) == 1 {
|
||||
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/start.zip?token=initial-secret",
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
var dialCount atomic.Int32
|
||||
dependencies := remoteSourceDependencies{
|
||||
resolver: resolver,
|
||||
dialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
dialCount.Add(1)
|
||||
return nil, errors.New("unexpected dial")
|
||||
},
|
||||
createTemp: os.CreateTemp,
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRemoteSource() error = %v", err)
|
||||
}
|
||||
rawURL := "http://rebind.example/site.zip?signature=dns-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderBlockedAddress) {
|
||||
t.Fatalf("fetchRemoteSource() error = %v, want DNS rebinding rejection", err)
|
||||
defer func() { _ = candidate.Cleanup() }()
|
||||
if candidate.Format != "zip" {
|
||||
t.Fatalf("candidate format = %q, want zip", candidate.Format)
|
||||
}
|
||||
if lookupCount.Load() != 2 {
|
||||
t.Fatalf("DNS lookup count = %d, want preflight plus dial validation", lookupCount.Load())
|
||||
}
|
||||
if dialCount.Load() != 0 {
|
||||
t.Fatalf("direct dial count = %d, rebound address must not be dialed", dialCount.Load())
|
||||
}
|
||||
assertRemoteSourceErrorRedacted(t, err, rawURL, "dns-secret", "signature=")
|
||||
}
|
||||
|
||||
func TestFetchRemoteSourcePublicRejectsSelfSignedTLS(t *testing.T) {
|
||||
@@ -240,7 +207,7 @@ func TestFetchRemoteSourcePublicRejectsSelfSignedTLS(t *testing.T) {
|
||||
rawURL := "https://artifact.example/site.zip?signature=tls-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderDownloadFailed) {
|
||||
@@ -268,7 +235,7 @@ func TestFetchRemoteSourceRejectsChunkedBodyOverLimitAndCleansTemp(t *testing.T)
|
||||
rawURL := server.URL + "/site.zip?token=chunk-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: maxPackageBytes,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
@@ -293,7 +260,7 @@ func TestFetchRemoteSourceRejectsContentLengthBeforeCreatingTemp(t *testing.T) {
|
||||
}
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/site.zip",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderTooLarge) {
|
||||
@@ -314,7 +281,7 @@ func TestFetchRemoteSourceSniffsAtLeast512BytesForTar(t *testing.T) {
|
||||
|
||||
candidate, err := FetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: server.URL + "/download",
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -344,7 +311,7 @@ func TestFetchRemoteSourceRedactsURLHeadersAndBodyFromErrors(t *testing.T) {
|
||||
rawURL := server.URL + "/download?token=query-secret"
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: rawURL,
|
||||
NetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
AllowInsecure: true,
|
||||
MaxPackageBytes: 1024,
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderUnsupported) {
|
||||
@@ -380,7 +347,7 @@ func TestFetchRemoteSourceAllowsFiveRedirectsOnly(t *testing.T) {
|
||||
dependencies := mappedRemoteSourceDependencies(server.Listener.Addr().String(), staticPublicRemoteSourceResolver())
|
||||
_, err := fetchRemoteSource(t.Context(), RemoteSourceRequest{
|
||||
URL: "http://artifact.example/0",
|
||||
NetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
MaxPackageBytes: int64(len(packageBytes) + 1),
|
||||
}, dependencies)
|
||||
if !errors.Is(err, errRemoteProviderRedirectLimit) {
|
||||
|
||||
@@ -54,7 +54,7 @@ type sourceExecutionSnapshot struct {
|
||||
SourceType string
|
||||
SourceIdentity string
|
||||
RemoteURL string
|
||||
RemoteNetworkPolicy string
|
||||
AllowInsecure bool
|
||||
GitHubRepository string
|
||||
ReleaseSelector string
|
||||
ReleaseTag string
|
||||
@@ -176,7 +176,7 @@ func loadSourceExecutionSnapshot(
|
||||
SourceType: source.SourceType,
|
||||
SourceIdentity: source.SourceIdentity,
|
||||
RemoteURL: source.RemoteURL,
|
||||
RemoteNetworkPolicy: source.RemoteNetworkPolicy,
|
||||
AllowInsecure: source.AllowInsecure,
|
||||
GitHubRepository: source.GitHubRepository,
|
||||
ReleaseSelector: source.ReleaseSelector,
|
||||
ReleaseTag: source.ReleaseTag,
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestSourceLeaseHeartbeatRenewsAndCancelsOnOwnershipLoss(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
if err != nil || outcome != sourceLeaseAcquired || snapshot == nil {
|
||||
@@ -83,7 +83,7 @@ func TestAcquireSourceLeaseConcurrentOnlyOneOwner(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
type leaseResult struct {
|
||||
snapshot *sourceExecutionSnapshot
|
||||
@@ -138,7 +138,7 @@ func TestAcquireSourceLeaseMutualExclusionExpiryAndTerminalOwnership(t *testing.
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
|
||||
first, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
@@ -234,7 +234,7 @@ func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=first",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
|
||||
configSnapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
||||
@@ -243,9 +243,8 @@ func TestSourceConfigAndProjectContentChangesFenceLease(t *testing.T) {
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip?token=second",
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
AllowInsecure: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(config fence) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ func prepareRemoteSource(
|
||||
task.AppendLog(ctx, "[download] 正在获取远程部署包")
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: snapshot.RemoteURL,
|
||||
NetworkPolicy: snapshot.RemoteNetworkPolicy,
|
||||
AllowInsecure: snapshot.AllowInsecure,
|
||||
MaxPackageBytes: limits.PackageBytes,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestSyncRemoteSourceAtomicallyActivatesAndReusesChecksum(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
firstSnapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
@@ -220,7 +220,7 @@ func TestSyncRemoteSourceDownloadFailureKeepsOldActive(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:2")
|
||||
@@ -244,7 +244,7 @@ func TestSyncRemoteSourceArchiveFailureKeepsOldActive(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
_, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:3")
|
||||
@@ -275,7 +275,7 @@ func TestSyncRemoteSourceFinalFenceCompensatesIngest(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip?token=final-fence-secret",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
|
||||
outcome, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:4")
|
||||
@@ -345,7 +345,7 @@ func TestCommitSourceDeploymentRechecksLeaseAfterUploadLocks(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
first, err := syncRemoteSource(ctx, mustAcquireRemoteSyncLease(t, ctx, source), "user:5")
|
||||
if err != nil || first == nil || first.Deployment == nil {
|
||||
@@ -427,7 +427,7 @@ func TestCompensateSourceIngestSurvivesCanceledParentContext(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
packageBytes := testPagesZip(t, map[string]string{"index.html": "cancel-compensation"})
|
||||
packagePath := filepath.Join(t.TempDir(), "site.zip")
|
||||
@@ -517,7 +517,7 @@ func TestCommitSourceDeploymentRejectsDeletedTargetUpload(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
server.URL+"/site.zip",
|
||||
RemoteNetworkPolicyTrustedInternal,
|
||||
true,
|
||||
)
|
||||
snapshot := mustAcquireRemoteSyncLease(t, ctx, source)
|
||||
|
||||
|
||||
@@ -278,7 +278,6 @@ func isPermanentSourceSyncError(err error) bool {
|
||||
strings.Contains(message, errPagesPackageFileTooLarge) ||
|
||||
strings.Contains(message, errPagesEntryFileMissing) ||
|
||||
strings.Contains(message, errPagesSourceRemoteURLInvalid) ||
|
||||
strings.Contains(message, errPagesSourceNetworkPolicy) ||
|
||||
strings.Contains(message, errPagesSourceReleaseNotFound) ||
|
||||
strings.Contains(message, errPagesSourceDigestInvalid) ||
|
||||
strings.Contains(message, errPagesSourceDigestMismatch) ||
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestRemoteCheckActionIsPermanentWithoutExposingURL(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token="+secret,
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
raw, err := json.Marshal(SourceActionPayload{
|
||||
SourceID: source.ID,
|
||||
|
||||
@@ -5,7 +5,6 @@ package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -80,14 +79,13 @@ func mustConfigureRemoteSource(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
remoteURL string,
|
||||
policy string,
|
||||
allowInsecure bool,
|
||||
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
||||
t.Helper()
|
||||
_, err := UpdateSource(ctx, projectID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: remoteURL,
|
||||
RemoteNetworkPolicy: policy,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: remoteURL,
|
||||
AllowInsecure: allowInsecure,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSource(%d, %q) error = %v, want nil", projectID, remoteURL, err)
|
||||
@@ -111,23 +109,14 @@ func TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
|
||||
{
|
||||
name: "missing source type",
|
||||
input: SourceUpdateInput{
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "github type reserved for phase two",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeGitHubRelease,
|
||||
RepositoryURL: "https://github.com/example/site",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote rejects repository field",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RepositoryURL: "https://github.com/example/site",
|
||||
},
|
||||
},
|
||||
@@ -135,25 +124,14 @@ func TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
|
||||
name: "remote rejects automatic updates",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
AutoUpdateEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "url value requires replacement flag",
|
||||
name: "missing remote url",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid network policy",
|
||||
input: SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: "https://example.com/site.zip",
|
||||
RemoteNetworkPolicy: "private",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -185,7 +163,7 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
ctx := setupPagesSourceTest(t)
|
||||
project := mustCreatePagesSourceProject(t, ctx, "remote-crud")
|
||||
firstURL := "https://Artifacts.Example.com:443/dist/site.zip?token=first-secret&expires=1"
|
||||
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, RemoteNetworkPolicyPublic)
|
||||
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, false)
|
||||
|
||||
if got, want := source.ConfigVersion, 1; got != want {
|
||||
t.Errorf("new source ConfigVersion = %d, want %d", got, want)
|
||||
@@ -197,21 +175,12 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if got, want := view.DisplayURL, "https://Artifacts.Example.com:443/dist/site.zip?***"; got != want {
|
||||
t.Errorf("GetSource(%d).DisplayURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
encodedView, err := json.Marshal(view)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(GetSource(%d)) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
if strings.Contains(string(encodedView), "first-secret") || strings.Contains(string(encodedView), "expires=1") {
|
||||
t.Errorf("GetSource(%d) JSON = %s, want credential-free view", project.ID, encodedView)
|
||||
if got, want := view.RemoteURL, firstURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: firstURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, no-op) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -240,12 +209,12 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
t.Fatalf("seed source runtime error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Omit the secret URL while changing policy. The stored URL and cursor must
|
||||
// Keep the same URL while enabling insecure TLS. The identity and cursor must
|
||||
// survive, while the in-flight lease is fenced.
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: false,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: firstURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, preserve URL) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -275,10 +244,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
// Replacing only the query secret keeps the canonical identity and cursors.
|
||||
queryReplacementURL := "https://artifacts.example.com/dist/site.zip?token=second-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: queryReplacementURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: queryReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, query replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -296,10 +264,9 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
// Replacing the path changes identity and clears all remote cursors.
|
||||
pathReplacementURL := "https://artifacts.example.com/dist/other.zip?token=third-secret"
|
||||
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURLSet: true,
|
||||
RemoteURL: pathReplacementURL,
|
||||
RemoteNetworkPolicy: RemoteNetworkPolicyTrustedInternal,
|
||||
SourceType: PagesSourceTypeRemoteURL,
|
||||
RemoteURL: pathReplacementURL,
|
||||
AllowInsecure: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateSource(%d, path replacement) error = %v, want nil", project.ID, err)
|
||||
}
|
||||
@@ -320,14 +287,8 @@ func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSource(%d) after path replacement error = %v, want nil", project.ID, err)
|
||||
}
|
||||
pathJSON, err := json.Marshal(pathView)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(path view) error = %v, want nil", err)
|
||||
}
|
||||
for _, secret := range []string{"first-secret", "second-secret", "third-secret"} {
|
||||
if strings.Contains(string(pathJSON), secret) {
|
||||
t.Errorf("path view JSON = %s, want no secret %q", pathJSON, secret)
|
||||
}
|
||||
if got, want := pathView.RemoteURL, pathReplacementURL; got != want {
|
||||
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +314,7 @@ func TestDeleteSourceIsIdempotentAndKeepsDeploymentState(t *testing.T) {
|
||||
ctx,
|
||||
project.ID,
|
||||
"https://example.com/site.zip?token=delete-secret",
|
||||
RemoteNetworkPolicyPublic,
|
||||
false,
|
||||
)
|
||||
deployment := &model.PagesDeployment{
|
||||
ProjectID: project.ID,
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS of_pages_project_sources (
|
||||
project_id BIGINT NOT NULL,
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT '',
|
||||
remote_url TEXT NOT NULL DEFAULT '',
|
||||
remote_network_policy VARCHAR(32) NOT NULL DEFAULT '',
|
||||
allow_insecure BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
github_repository VARCHAR(255) NOT NULL DEFAULT '',
|
||||
release_selector VARCHAR(16) NOT NULL DEFAULT '',
|
||||
release_tag VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS of_pages_project_sources (
|
||||
project_id INTEGER NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT '',
|
||||
remote_url TEXT NOT NULL DEFAULT '',
|
||||
remote_network_policy TEXT NOT NULL DEFAULT '',
|
||||
allow_insecure INTEGER NOT NULL DEFAULT 0,
|
||||
github_repository TEXT NOT NULL DEFAULT '',
|
||||
release_selector TEXT NOT NULL DEFAULT '',
|
||||
release_tag TEXT NOT NULL DEFAULT '',
|
||||
|
||||
@@ -175,7 +175,7 @@ func createMigrationSourceRuntime(t *testing.T, gormDB *gorm.DB) uint {
|
||||
ProjectID: pagesMigrationProjectID,
|
||||
SourceType: "remote_url",
|
||||
RemoteURL: "https://example.com/site.zip?token=secret",
|
||||
RemoteNetworkPolicy: "public",
|
||||
AllowInsecure: false,
|
||||
CheckIntervalMinutes: 0,
|
||||
ConfigVersion: 1,
|
||||
SourceIdentity: strings.Repeat("b", 64),
|
||||
|
||||
@@ -7,14 +7,13 @@ import "time"
|
||||
|
||||
// PagesProjectSource 保存 Pages 项目的持久部署源配置。
|
||||
//
|
||||
// RemoteURL 可能包含签名参数,禁止直接序列化 model;对外接口必须映射到
|
||||
// pages 包内的脱敏 source view。
|
||||
// 对外接口必须映射到 pages 包内的 source view,避免直接序列化 model。
|
||||
type PagesProjectSource struct {
|
||||
ID uint `json:"-" gorm:"primaryKey;autoIncrement"`
|
||||
ProjectID uint `json:"-" gorm:"not null;uniqueIndex:idx_of_pages_project_sources_project_id"`
|
||||
SourceType string `json:"-" gorm:"size:32;not null;default:''"`
|
||||
RemoteURL string `json:"-" gorm:"type:text;not null;default:''"`
|
||||
RemoteNetworkPolicy string `json:"-" gorm:"size:32;not null;default:''"`
|
||||
AllowInsecure bool `json:"-" gorm:"not null;default:false"`
|
||||
GitHubRepository string `json:"-" gorm:"column:github_repository;size:255;not null;default:''"`
|
||||
ReleaseSelector string `json:"-" gorm:"size:16;not null;default:''"`
|
||||
ReleaseTag string `json:"-" gorm:"size:255;not null;default:''"`
|
||||
|
||||
Reference in New Issue
Block a user