Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,37 @@ jobs:
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
"$(go env GOPATH)/bin/govulncheck" ./...

web-and-security:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json

- name: Install locked dependencies
run: npm ci

- name: Svelte check
run: npm run check

- name: Build embedded Web UI
run: npm run build

- name: Audit dependencies
run: npm audit --audit-level=moderate

secret-scan:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6

- name: Scan tracked source for credential patterns
run: bash scripts/secret-scan.sh
16 changes: 11 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ IronClaw 실행 계층으로 통합하는 downstream product fork다. 이 저장
경로와 기존 API/환경 변수에는 `ephemera` 또는 `goose` 이름이 남아 있다. anvil
통합 릴리즈는 ephemera runtime tag와 충돌하지 않도록 `anvil-v0.1.0`처럼 별도
prefix를 사용한다. **`anvil-v0.7.0`부터 anvil 공개 릴리즈 버전은 upstream ephemera
버전과 동일하게 정렬한다.** 현재 최신(Latest) 릴리즈는 `anvil-v0.7.0`이고 tag
버전과 동일하게 정렬한다. 다음 공개 version은 upstream의 첫 post-`v0.7.0` 정식 tag를
`anvil-<upstream-tag>`로 정확히 변환한다. downstream-only patch/minor 번호를 만들지
않는다. 2026-08-13 현재 upstream latest/main은 `v0.7.0`이므로 다음 version number는
미할당이며 release blocker다.** 현재 최신(Latest) 릴리즈는 `anvil-v0.7.0`이고 tag
target은 `2f367dd`(태그 시점 main; parity + release-gate hardening + post-release
backlog + open-gate 마감; full KVM e2e 343✓, step 59 실 LLM 포함)이며 설치 아티팩트
(SLIM/FULL tarball + sha256)를 제공한다. 이후 main은 cross-host shared Town
Expand Down Expand Up @@ -464,8 +467,8 @@ daemon으로 보내는 outbound Bearer token이다.
상세: [`docs/operations/2026-07-11-6b-failover-verification-run.md`](docs/operations/2026-07-11-6b-failover-verification-run.md).
- **egress L7/SNI hardening이 구현 완료됐다**(2026-07-13/14, `feature/egress-sni-filter`,
최소 3개 릴리즈(`v0.2.x`~`v0.3.1`)에서 이월되던 후속 후보를 해소). `egressProfile`에
신규 `allow_sni []string` 필드(기존 `allow_hosts` substring 재해석 아님, 하위호환
additive, `*.` leading-label wildcard)를 추가하고, :443 새 흐름의 ClientHello를
신규 `allow_sni []string` 필드(`*.` leading-label wildcard)를 추가하고, :443 새
흐름의 ClientHello를
`iptables -j NFQUEUE --queue-num 88`(env `ANVIL_SNI_QUEUE_NUM`)로 goose-daemon
**in-process** verdict 루프(`github.com/florianl/go-nfqueue/v2` — 이 slice의 유일한
신규 direct 의존)에 dispatch해 실제 파싱된 SNI를 매칭한다. 허용 흐름은 conntrack
Expand Down Expand Up @@ -552,8 +555,11 @@ daemon으로 보내는 outbound Bearer token이다.
PASS(왕복+경계 3종, 신규 결함 없음).
[`docs/operations/2026-07-13-mcp-gateway-deployment-verification-run.md`](docs/operations/2026-07-13-mcp-gateway-deployment-verification-run.md).
- egress SNI 필터 후속(ADR-0002 잔여 위험/설계 한계에서 파생):
`allow_hosts` 제거 시점은 **2026-07-18 deprecation cycle 확정**(release N 런타임
경고 추가 + release N+1(다음 tagged anvil 릴리즈) 제거·loud fail-closed — OQ8).
~~`allow_hosts` 제거~~는 **2026-08-13 완료**됐다. 2026-07-18에 확정한 deprecation
cycle의 release N 경고 단계를 거쳐 field·validation·iptables packet substring apply
path를 제거했고, 잔존 `allow_hosts` key는 non-empty/empty/`null` 및 과거
case-insensitive spelling 모두 profile decode에서 loud fail-closed로 거부한다(OQ8).
마이그레이션은 domain → `allow_sni`, IP/CIDR → `allow_cidrs`다.
multi-queue per-VM NFQUEUE는 **2026-07-18 분석 후 YAGNI 확정**(단일 queue 88 +
connmark fast-path가 새 flow ClientHello만 처리해 병목 아님; 공유 reassembler
LRU 4096은 신뢰-워크로드 모델·실 스케일에 headroom 충분; 재개 트리거 = untrusted
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ Web UI 사용법은 [`docs/guides/runtime-usage.md`](docs/guides/runtime-usage.m
도메인 단위로 강제한다. TCP는 파싱된 TLS ClientHello SNI, QUIC/UDP:443은 자체 구현
QUIC Initial 복호(HKDF+AES-128-GCM+header protection, QUICv1/v2)로 SNI를 추출해
goose-daemon in-process NFQUEUE verdict 루프가 fail-closed로 판정한다(허용=conntrack
connmark 커널 fast-path, 비허용=DROP). CIDR allow가 SNI보다 상위 계약이다.
connmark 커널 fast-path, 비허용=DROP). CIDR allow가 SNI보다 상위 계약이다. 제거된
`allow_hosts` key는 값이 비어 있어도 profile load가 실패하며, 도메인은 `allow_sni`,
IP/CIDR은 `allow_cidrs`로 마이그레이션해야 한다.
- **운영 표면** — Operator CLI `ephemera-ctl`, 브라우저 Web UI(`/ui/`, EN/KO),
control-plane 인증(named token·per-token TTL·SIGHUP hot rotation), Prometheus
`/metrics`, access audit log, end-user installer(`install.sh`/`uninstall.sh`).
Expand Down Expand Up @@ -135,6 +137,9 @@ rewrite 없음). ephemera runtime release tag는 `v*`, anvil product release tag
[`CONTEXT.md`](CONTEXT.md)). 이후 main은 cross-host routed flock(공유 Town Wall·gtcall·
home 재선출 failover), snapshot replication 자동화, egress SNI/L7 필터(TCP+QUIC)와 복구
무결성 하드닝 등 untagged 작업(PR #19 이후)을 더 포함한다. 첫 공개 tag는 `anvil-v0.1.0`이다.
다음 공개 version은 upstream의 첫 post-`v0.7.0` 정식 tag와 같은 번호만 사용한다
(`vX.Y.Z` → `anvil-vX.Y.Z`). 2026-08-13 현재 새 upstream tag가 없어 다음 번호는
미할당이며, downstream-only 번호나 추정 tag는 만들지 않는다.

remote 설정과 upstream sync 절차 전체는
[`docs/operations/upstream-sync-policy.md`](docs/operations/upstream-sync-policy.md)에 있다.
Expand All @@ -150,7 +155,7 @@ anvil main runtime baseline은 upstream ephemera `v0.7.0`까지를 병합·적
| 구분 | 현재 기준 | anvil에서의 의미 |
|---|---|---|
| ephemera runtime baseline | `v0.7.0` | Firecracker VM lifecycle, cold-restart, flock recovery, token rotation, `/metrics`, `/stats`, `slog`, in-VM `gtcall`, webdev demo, v0.4.0-v0.4.5 runtime 안정화(auth/audit, COW spawn, dynamic flock lifecycle, streaming task, nested depth guard, watchdog status, snapshot-restore auto-recovery), v0.5.0-v0.5.5 operator support(Web UI `/ui/`, `/config/*`, per-VM sizing `1` vCPU/`1024` MiB default), v0.6.0-v0.6.4 runtime MCP Gateway(`EPHEMERA_MCP_*`, anti-spoof/rate-limit/stdio backends), v0.7.0 end-user installer + conversation transcript restore 기반. upstream parity scope(v0.4.0-v0.7.0) 코드 편입 완료 |
| upstream latest observed | `v0.7.0` (2026-07-02 확인) | 관찰 범위 전체 병합·적응 완료, pending sync 후보 없음 |
| upstream latest observed | `v0.7.0` (2026-08-13 확인) | upstream main=`v0.7.0`; 관찰 범위 전체 병합·적응 완료, pending sync 후보 없음 |
| anvil product surface | `anvil_*` MCP tool, scheduler, tenant/egress, workload runner | IronClaw가 직접 사용하는 공개 실행 계약 |
| namespace policy | `EPHEMERA_*`, `goose-*`, `ephemera_*` 유지 | upstream runtime 호환성. anvil 이름으로 일괄 rename하지 않는다. |

Expand All @@ -165,6 +170,7 @@ anvil control plane은 named token 인증에 per-token TTL과 SIGHUP hot rotatio
Prometheus `/metrics`와 access audit log로 운영 가시성을 남긴다. guest egress는 profile
`allow_sni`로 :443을 도메인 단위 fail-closed로 강제한다(TCP ClientHello + QUIC Initial 자체
복호, [`docs/adr/0002-egress-sni-transparent-filter.md`](docs/adr/0002-egress-sni-transparent-filter.md)).
legacy packet-substring `allow_hosts`는 제거됐고 잔존 key는 loud fail-closed로 거부된다.
보안 모델, 알려진 제약, Resilience·Observability 전체는
[`docs/guides/security-and-resilience.md`](docs/guides/security-and-resilience.md)에 있다.
공개 노출, 제어 평면 token, guest agent token, snapshot metadata 반출 정책은
Expand Down
18 changes: 15 additions & 3 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ preflight, [ADR-0002](docs/adr/0002-egress-sni-transparent-filter.md))·
**UDP:443(QUIC/HTTP3) SNI 필터 확장**(같은 ADR-0002, 신규 패키지
`internal/network/quic`로 QUIC Initial을 자체 복호해 같은 `allow_sni` 매처를
UDP에도 적용, post-quantum multi-datagram ClientHello 재조립, 신규 direct 의존
`golang.org/x/crypto`)·`allow_hosts` deprecation cycle 확정(런타임 경고, 다음
tagged 릴리즈에서 제거)·egress ECH 관측 metric(`ephemera_egress_sni_ech_observed_total`)·
`golang.org/x/crypto`)·`allow_hosts` deprecation cycle 완료(2026-08-13 제거,
잔존 key는 값이 비어 있어도 `allow_sni`/`allow_cidrs` migration error로 loud
fail-closed 거부)·egress ECH 관측 metric(`ephemera_egress_sni_ech_observed_total`)·
scheduler aggregate quota metric(`anvil_scheduler_quota_*`)·CI gofmt gate(빌드
전 `gofmt -l .` 검사)·**보안 하드닝 시리즈**(host-local 파일 권한, listener pre-auth
한계, adapter/gateway 입력 검증, Town Wall 레코드 무결성, supply chain·release
Expand All @@ -21,7 +22,10 @@ workflow, pinned artifact 검증 — PR #91~#99, 아래 「보안/운영 강화
KVM/Firecracker resume-race로 확정 → 2026-07-13 **종결**(default plain 유지·COW
opt-in, fc v1.16.1이 실패율 100%→~15–25%로 최대 완화) — `docs/ADR_INDEX.md` v0.4.2 행
참조.) anvil 버전은 이제
upstream ephemera 버전을 따른다. 이 릴리즈는 아래 `anvil-v0.4.0` 절의 parity
upstream ephemera 버전을 따른다. 다음 public version은 upstream의 첫 post-`v0.7.0`
정식 tag를 `anvil-<upstream-tag>`로 정확히 변환하며 downstream-only 번호를 만들지
않는다. 2026-08-13 현재 upstream latest/main이 `v0.7.0`이므로 다음 번호는 미할당
release blocker다. 이 릴리즈는 아래 `anvil-v0.4.0` 절의 parity
편입(`v0.4.0`-`v0.7.0`)에 더해, `anvil-v0.4.0`(`de82481`) 이후 main에 반영된
post-release backlog batch(PR #18 merge `726cbdc`)와 open-gate closure(step 59
실 LLM e2e 343✓)를 포함한 가장 완전한 상태다. backlog·open-gate 항목은 모두 anvil
Expand Down Expand Up @@ -221,6 +225,14 @@ post-release backlog batch(PR #18 merge `726cbdc`)와 open-gate closure(step 59

## 유지보수

- Web dependency graph의 `esbuild`, `postcss`, `nanoid`를 patched transitive release로
override해 `npm audit --audit-level=moderate` 0건으로 닫았다(2026-08-13). CI는 기존
Go `build-and-test`와 별도로 Node 22 `web-and-security`(clean install/check/build/audit)와
`secret-scan` tracked-tree gate를 실행한다.
- explicit VM delete에서 `dmsetup remove --retry`가 실패해도 COW/base loop detach,
sparse `.cow` unlink와 TAP/IP release를 계속 시도하고 모든 error를 aggregate한다
(2026-08-13). forced failure unit test와 KVM 종료 후 resource-type별 inventory로
cleanup 불변 조건을 검증한다.
- web `npm audit` 비파괴 findings 정리(`a0fc935`). non-breaking fix가 없는 remainder는
breaking major 업그레이드(vite5→8, svelte4→5)뿐이라 별도 upgrade cycle로 defer한다.
- `cmd/goose-daemon/config_api.go`를 surface별로 분할한다(`a01d8da`, #19, 순수 이동):
Expand Down
23 changes: 18 additions & 5 deletions cmd/goose-daemon/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ type ControlPlane struct {
startMachine func(ctx context.Context, cfg vm.VMConfig) (*firecracker.Machine, error)
setupDMSnapshot func(baseDiskPath, exceptionStorePath, mountTargetPath string) (*storage.DMSnapshotInfo, error)
teardownDMSnapshot func(info *storage.DMSnapshotInfo)
deleteDMSnapshot func(info *storage.DMSnapshotInfo) error
setupBindMount func(baseDiskPath, newDiskPath, mountTargetPath string) error
restoreMachine func(ctx context.Context, cfg vm.VMConfig, memFilePath, snapshotPath string) (*firecracker.Machine, error)
setGuestAgentToken func(vsockPath, token string) error
Expand Down Expand Up @@ -1835,10 +1836,20 @@ func (cp *ControlPlane) destroyVMUnderSnapshotLock(vmID string) {
os.Remove(v.vsockPath)
}
cp.cleanupEgressPolicy(vmID)
cp.cleanupDeletedVMStorageAndNetwork(vmID, v)
cp.metrics.IncVMDelete()
slog.Warn("vm destroyed", "vm_id", vmID)
cp.metrics.vmDestroyTotal.WithLabelValues("ok").Inc()
}

func (cp *ControlPlane) cleanupDeletedVMStorageAndNetwork(vmID string, v *runningVM) {
if v.dmSnapshot != nil {
// COW-restored VM: release dm-snapshot device, loop device, and exception store.
if err := storage.TeardownDMSnapshot(v.dmSnapshot); err != nil {
teardown := storage.TeardownDMSnapshot
if cp.deleteDMSnapshot != nil {
teardown = cp.deleteDMSnapshot
}
if err := teardown(v.dmSnapshot); err != nil {
cp.metrics.IncCleanupFailure()
log.Printf("Warning: failed to teardown COW resources for VM [%s]: %v", vmID, err)
}
Expand All @@ -1850,10 +1861,12 @@ func (cp *ControlPlane) destroyVMUnderSnapshotLock(vmID string) {
slog.Warn("delete disk failed", "vm_id", vmID, "disk_path", v.diskPath, "err", err)
}
}
cp.netManager.Release(v.tapDevice, v.GuestIP)
cp.metrics.IncVMDelete()
slog.Warn("vm destroyed", "vm_id", vmID)
cp.metrics.vmDestroyTotal.WithLabelValues("ok").Inc()
// Network cleanup is independent from storage teardown. Never let a failed
// dm/loop/store operation skip TAP and IP lease release.
if err := cp.releaseAllocatedVMNetwork(v.tapDevice, v.GuestIP); err != nil {
cp.metrics.IncCleanupFailure()
slog.Warn("release deleted VM network failed", "vm_id", vmID, "tap_device", v.tapDevice, "guest_ip", v.GuestIP, "err", err)
}
}

// gracefulAgentStop best-effort asks a VM's goose-agent to shut down (POST /stop,
Expand Down
29 changes: 29 additions & 0 deletions cmd/goose-daemon/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,35 @@ func TestControlPlaneMetricsEndpoint(t *testing.T) {
}
}

func TestCleanupDeletedVMContinuesNetworkReleaseAfterDMSnapshotFailure(t *testing.T) {
cp := newTestCP(t)
var events []string
cp.deleteDMSnapshot = func(info *storage.DMSnapshotInfo) error {
events = append(events, "storage:"+info.DMDevice)
return errors.New("forced dm teardown failure")
}
cp.releaseVMNetwork = func(tapDevice, guestIP string) error {
events = append(events, "network:"+tapDevice+":"+guestIP)
return nil
}
v := &runningVM{
VMInfo: VMInfo{
VMID: "vm-delete-failure",
GuestIP: "10.0.1.44",
},
tapDevice: "tap-delete-failure",
dmSnapshot: &storage.DMSnapshotInfo{
DMDevice: "cow-vm-delete-failure.cow",
},
}

cp.cleanupDeletedVMStorageAndNetwork(v.VMID, v)

if got, want := strings.Join(events, ","), "storage:cow-vm-delete-failure.cow,network:tap-delete-failure:10.0.1.44"; got != want {
t.Fatalf("cleanup events = %q, want %q", got, want)
}
}

func TestControlPlaneMetricsEndpointIncludesDurationsAndQueueDepth(t *testing.T) {
cp := newTestCP(t)
cp.metrics.ObserveDuration("vm_create", 1500*time.Millisecond)
Expand Down
62 changes: 25 additions & 37 deletions cmd/goose-daemon/egress_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
Expand Down Expand Up @@ -38,7 +37,6 @@ func sniQueueNum() int {

type egressProfile struct {
AllowCIDRs []string `json:"allow_cidrs"`
AllowHosts []string `json:"allow_hosts"`
AllowSNI []string `json:"allow_sni"`
DNSServers []string `json:"dns_servers"`
}
Expand Down Expand Up @@ -66,37 +64,43 @@ func loadEgressProfile(baseDir, profile string) (egressProfile, bool, error) {
}
return egressProfile{}, false, fmt.Errorf("read egress profile: %w", err)
}
var profileConfig egressProfile
if err := json.Unmarshal(data, &profileConfig); err != nil {
profileConfig, err := decodeEgressProfile(data)
if err != nil {
return egressProfile{}, false, fmt.Errorf("parse egress profile: %w", err)
}
if err := validateEgressProfile(profileConfig); err != nil {
return egressProfile{}, false, err
}
if len(profileConfig.AllowHosts) > 0 {
// allow_hosts is a deprecated coarse substring matcher superseded by
// allow_sni (parsed ClientHello SNI) + allow_cidrs. Warn on every load so
// the docs-only deprecation becomes a runtime signal; the field is still
// applied (behavior unchanged) and is scheduled for removal in the next
// tagged anvil release (ADR-0002 OQ8). Content-free: profile name + count
// only, never the host values.
slog.Warn("egress profile uses deprecated allow_hosts (coarse packet substring match, fragmentation-evadable); migrate to allow_sni (parsed ClientHello SNI) for domains + allow_cidrs for IPs — allow_hosts will be removed in the next tagged anvil release",
"profile", profile, "allow_hosts_count", len(profileConfig.AllowHosts))
}
return profileConfig, true, nil
}

func decodeEgressProfile(data []byte) (egressProfile, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return egressProfile{}, err
}
for key := range raw {
// encoding/json matched struct fields case-insensitively while
// allow_hosts existed. Reject those legacy spellings too so removing the
// field can never turn an old profile into a silently ignored allow rule.
if strings.EqualFold(key, "allow_hosts") {
return egressProfile{}, fmt.Errorf("egress profile uses removed field allow_hosts; migrate domains to allow_sni and IP addresses or ranges to allow_cidrs")
}
}

var profileConfig egressProfile
if err := json.Unmarshal(data, &profileConfig); err != nil {
return egressProfile{}, err
}
return profileConfig, nil
}

func validateEgressProfile(profile egressProfile) error {
for _, cidr := range profile.AllowCIDRs {
if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
return fmt.Errorf("invalid allow_cidrs entry %q: %w", cidr, err)
}
}
for _, host := range profile.AllowHosts {
if err := validateEgressHost(host); err != nil {
return err
}
}
for _, entry := range profile.AllowSNI {
if err := validateEgressSNI(entry); err != nil {
return err
Expand All @@ -110,19 +114,9 @@ func validateEgressProfile(profile egressProfile) error {
return nil
}

func validateEgressHost(host string) error {
host = strings.TrimSpace(host)
if host == "" {
return fmt.Errorf("allow_hosts entries must be non-empty")
}
return validateDomainCharset("allow_hosts", host)
}

// validateDomainCharset enforces the shared ASCII domain charset (letters,
// validateDomainCharset enforces the allow_sni ASCII domain charset (letters,
// digits, '.', '-') on an already-trimmed, non-empty host. field names the JSON
// key the host came from ("allow_hosts" or "allow_sni") so the error points at
// the entry the operator actually wrote — validateEgressHost and
// validateEgressSNI both reuse this so the rule lives in one place.
// key so validation errors point at the entry the operator actually wrote.
func validateDomainCharset(field, host string) error {
for _, r := range host {
if r > 127 {
Expand Down Expand Up @@ -205,12 +199,6 @@ func planProfileEgressCommands(vmID, guestIP string, profile egressProfile) ([]e
Args: []string{"-I", "FORWARD", "-s", guestIP, "-d", strings.TrimSpace(cidr), "-j", "ACCEPT", "-m", "comment", "--comment", fmt.Sprintf("%s-cidr-%d", prefix, idx)},
})
}
for idx, host := range profile.AllowHosts {
commands = append(commands, egressCommand{
Name: "iptables",
Args: []string{"-I", "FORWARD", "-s", guestIP, "-m", "string", "--string", strings.TrimSpace(host), "--algo", "bm", "-j", "ACCEPT", "-m", "comment", "--comment", fmt.Sprintf("%s-host-%d", prefix, idx)},
})
}
return commands, nil
}

Expand Down
Loading