feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
@@ -50,34 +50,7 @@ func ValidateBaseURL(raw string) error {
|
||||
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the caller passed a literal IP, skip the DNS round-trip.
|
||||
if literal := net.ParseIP(host); literal != nil {
|
||||
if reason := blockReason(literal); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if reason := blockReason(ip.IP); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
|
||||
}
|
||||
}
|
||||
// Bind to the first resolved IP so a rebind between resolution
|
||||
// and connect cannot redirect the request to a blocked address.
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
},
|
||||
DialContext: SafeDialContext(dialer),
|
||||
MaxIdleConns: 16,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
@@ -85,6 +58,43 @@ func NewSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout, Transport: transport}
|
||||
}
|
||||
|
||||
// SafeDialContext returns a DialContext that rejects loopback, link-local,
|
||||
// multicast, unspecified, and cloud-metadata addresses at connect time,
|
||||
// re-resolving and binding to the resolved IP so a DNS rebind between
|
||||
// resolution and connect cannot slip through. Exposed so other transports
|
||||
// (e.g. the outbound notification client) can apply the same SSRF policy
|
||||
// without duplicating it or losing their own connection-pool tuning.
|
||||
func SafeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If the caller passed a literal IP, skip the DNS round-trip.
|
||||
if literal := net.ParseIP(host); literal != nil {
|
||||
if reason := blockReason(literal); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if reason := blockReason(ip.IP); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
|
||||
}
|
||||
}
|
||||
// Bind to the first resolved IP so a rebind between resolution
|
||||
// and connect cannot redirect the request to a blocked address.
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
|
||||
}
|
||||
}
|
||||
|
||||
// blockReason returns a human label for why an IP is rejected, or ""
|
||||
// if the IP is allowed. Centralized so all callers share the same
|
||||
// policy.
|
||||
@@ -92,6 +102,13 @@ func blockReason(ip net.IP) string {
|
||||
if ip == nil {
|
||||
return "nil address"
|
||||
}
|
||||
// Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) so the loopback / link-local
|
||||
// classifiers below catch them. net.IP.To4() returns the 4-byte form for
|
||||
// IPv4-mapped addresses; net's IsLoopback already handles this, but pin
|
||||
// the conversion to avoid future surprises if the std-lib semantics drift.
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
switch {
|
||||
case ip.IsLoopback():
|
||||
return "loopback"
|
||||
@@ -104,5 +121,22 @@ func blockReason(ip net.IP) string {
|
||||
case ip.IsMulticast():
|
||||
return "multicast"
|
||||
}
|
||||
// Cloud metadata endpoints — AWS / GCP / Azure are covered by the
|
||||
// link-local block (169.254.169.254). The rest must be enumerated.
|
||||
if metadataIPSet[ip.String()] {
|
||||
return "cloud metadata endpoint"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// metadataIPSet enumerates well-known cloud metadata IPs that are NOT
|
||||
// covered by net.IP.IsLinkLocalUnicast. Updating this set is the lightest
|
||||
// way to keep up with new providers without changing the policy shape.
|
||||
var metadataIPSet = map[string]bool{
|
||||
// Alibaba Cloud ECS metadata.
|
||||
"100.100.100.200": true,
|
||||
// Oracle Cloud Infrastructure metadata.
|
||||
"192.0.0.192": true,
|
||||
// AWS IMDS over IPv6 (ULA — not link-local, must be listed).
|
||||
"fd00:ec2::254": true,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user