33 Commits

Author SHA1 Message Date
tursom
f508ecc1b9 docs: 补充中文代码注释
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2026-06-27 20:15:29 +08:00
tursom
7a5664ab89 feat(plugin): support multi-format manifest sources 2026-06-27 18:28:03 +08:00
tursom
e4dfcb62cb feat(plugin): add development toolchain 2026-06-27 10:19:57 +08:00
tursom
f22005ea37 refactor(plugin): use manifest-only metadata
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2026-06-26 14:58:50 +08:00
tursom
54f5c322e9 feat(plugin): add future runtime distribution controls
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 13:31:24 +08:00
tursom
f3fb924a3a feat(plugin): add extension ecosystem 2026-06-26 13:09:38 +08:00
tursom
91eee02243 feat(plugin): add observability operations
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 12:32:45 +08:00
tursom
629d6d5dbc feat(plugin): add governance release gates
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 12:09:20 +08:00
tursom
b822993489 feat(plugin): add admin plugin management UI
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 11:38:19 +08:00
tursom
eae2319328 feat(plugin): add source package builder
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 10:53:41 +08:00
tursom
ae8706f8c8 feat(plugin): add protocol proxy mode
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 10:13:00 +08:00
tursom
f4a11fb770 feat(plugin): add managed binary plugin MVP
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
Docker Image / docker (push) Has been cancelled
2026-06-26 09:41:30 +08:00
tursom
c21edfdf1a docs: add plugin system design plan 2026-06-26 08:59:59 +08:00
tursom
ecb682bb41 Refactor admin frontend build
Some checks failed
Docker Image / docker (push) Has been cancelled
2026-06-25 21:16:11 +08:00
tursom
bf6db8c455 Split compose build override from deploy config 2026-06-25 18:46:17 +08:00
tursom
4fbba07b27 Fix multi-arch Docker build without QEMU 2026-06-25 12:36:51 +08:00
tursom
49381901df Fix gateway cross-build without sqlite support 2026-06-25 11:44:16 +08:00
tursom
04ec53bc51 feat: add docker deployment support 2026-06-25 11:05:30 +08:00
tursom
13ab1f6964 feat: add sqlite-backed admin management
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2026-06-25 08:53:44 +08:00
tursom
ada051341d 实现 TCP 与 WebSocket 端口复用 2026-06-24 18:23:08 +08:00
tursom
935fe4b808 test: 补全单元测试
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2026-06-23 13:06:31 +08:00
tursom
ca9241595e 优化网关转发性能 2026-06-23 12:17:13 +08:00
tursom
b46d057b80 update 2026-06-23 11:08:10 +08:00
tursom
ef9cca7440 添加 haproxy 协议支持
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / build (wasm, js, js-wasm) (push) Has been cancelled
Go / build (wasm, wasip1, wasip1-wasm) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
添加插件功能支持
2025-07-10 12:00:31 +08:00
tursom
9e80512948 add support for websocket 2025-07-06 21:07:54 +08:00
tursom
55c2f0a888 upstream 支持 quic 和 kcp 2025-07-01 18:38:50 +08:00
tursom
8530df6cdf 支持 KCP 协议
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled
2025-07-01 08:02:04 +08:00
tursom
0e5e0ced30 支持 QUIC 协议中转 2025-06-25 11:33:11 +08:00
tursom
c6050d6824 优化性能,适配 logrotate 2025-06-24 11:13:21 +08:00
tursom
9bc1be1020 add log config support 2024-10-25 15:05:06 +08:00
Tursom K. Ulefits
e0a33c3a0d Merge pull request #1 from TomatoPuddin/pr/fix-forge-conn
Fix forge connection
2024-10-22 10:54:44 +08:00
TomatoPuddin
095d69d8a3 Fix forge connection. 2024-10-20 08:22:55 +08:00
tursom
48cea3d046 add panic revocer
Some checks failed
Go / build (push) Has been cancelled
2024-10-08 15:49:44 +08:00
178 changed files with 45966 additions and 179 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
.git
.agents
.codex
.omc
.vscode
node_modules
cmd/gateway/admin_static/js
logs
data
gateway
gateway-*
kcp
kcp-*
quic
quic-*
*.so
*.sqlite3
*.sqlite3-*
config.toml

214
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,214 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Linux builds
- goos: linux
goarch: amd64
name: linux-amd64
- goos: linux
goarch: arm64
name: linux-arm64
- goos: linux
goarch: arm
goarm: 7
name: linux-armv7
- goos: linux
goarch: arm
goarm: 6
name: linux-armv6
- goos: linux
goarch: 386
name: linux-386
- goos: linux
goarch: loong64
name: linux-loong64
- goos: linux
goarch: mips
name: linux-mips
- goos: linux
goarch: mips64
name: linux-mips64
- goos: linux
goarch: mips64le
name: linux-mips64le
- goos: linux
goarch: mipsle
name: linux-mipsle
- goos: linux
goarch: ppc64
name: linux-ppc64
- goos: linux
goarch: ppc64le
name: linux-ppc64le
- goos: linux
goarch: riscv64
name: linux-riscv64
- goos: linux
goarch: s390x
name: linux-s390x
# Windows builds
- goos: windows
goarch: amd64
name: windows-amd64
ext: .exe
- goos: windows
goarch: arm64
name: windows-arm64
ext: .exe
- goos: windows
goarch: 386
name: windows-386
ext: .exe
# macOS builds
- goos: darwin
goarch: amd64
name: darwin-amd64
- goos: darwin
goarch: arm64
name: darwin-arm64
# FreeBSD builds
- goos: freebsd
goarch: amd64
name: freebsd-amd64
- goos: freebsd
goarch: arm64
name: freebsd-arm64
- goos: freebsd
goarch: 386
name: freebsd-386
- goos: freebsd
goarch: arm
name: freebsd-arm
- goos: freebsd
goarch: riscv64
name: freebsd-riscv64
- goos: aix
goarch: ppc64
name: aix-ppc64
- goos: dragonfly
goarch: amd64
name: dragonfly-amd64
- goos: illumos
goarch: amd64
name: illumos-amd64
- goos: netbsd
goarch: 386
name: netbsd-386
- goos: netbsd
goarch: amd64
name: netbsd-amd64
- goos: netbsd
goarch: arm
name: netbsd-arm
- goos: netbsd
goarch: arm64
name: netbsd-arm64
- goos: openbsd
goarch: 386
name: openbsd-386
- goos: openbsd
goarch: amd64
name: openbsd-amd64
- goos: openbsd
goarch: arm
name: openbsd-arm
- goos: openbsd
goarch: arm64
name: openbsd-arm64
- goos: openbsd
goarch: ppc64
name: openbsd-ppc64
- goos: openbsd
goarch: riscv64
name: openbsd-riscv64
- goos: plan9
goarch: 386
name: plan9-386
- goos: plan9
goarch: amd64
name: plan9-amd64
- goos: plan9
goarch: arm
name: plan9-arm
- goos: solaris
goarch: amd64
name: solaris-amd64
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23.1'
- name: Build Gateway
run: go build -ldflags="-s -w" -o gateway-${{ matrix.name }}${{ matrix.ext }} ./cmd/gateway
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
- name: Build KCP
run: go build -ldflags="-s -w" -o kcp-${{ matrix.name }}${{ matrix.ext }} ./cmd/kcp
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
- name: Build QUIC
run: go build -ldflags="-s -w" -o quic-${{ matrix.name }}${{ matrix.ext }} ./cmd/quic
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
- name: Upload artifacts artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.name }}
path: |
gateway-${{ matrix.name }}${{ matrix.ext }}
kcp-${{ matrix.name }}${{ matrix.ext }}
quic-${{ matrix.name }}${{ matrix.ext }}
# 合并所有构建产物
merge-artifacts:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
merge-multiple: true # 合并所有 artifacts
- name: Display structure
run: ls -la ./artifacts
- name: Upload merged artifacts
uses: actions/upload-artifact@v4
with:
name: mc-gateway-all-platforms
path: ./artifacts/*
retention-days: 30

60
.github/workflows/docker-image.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Docker Image
on:
push:
branches: ["master"]
tags: ["v*"]
pull_request:
branches: ["master"]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,28 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23.1'
- name: Build
run: go build -ldflags="-s -w" -v ./...
- name: Test
run: go test -v ./...

14
.gitignore vendored
View File

@@ -1,2 +1,12 @@
config.json
/gateway
.vscode
node_modules/
cmd/gateway/admin_static/js/
config.toml
/gateway*
/kcp*
/quic*
/logs/
/data/
.omc

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Dockerfile 构建网关二进制、编译管理前端,并打包带 SQLite 友好默认值的运行镜像。
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend
WORKDIR /src
COPY package.json package-lock.json tsconfig.admin.json ./
RUN --mount=type=cache,id=mc-gateway-npm,target=/root/.npm,sharing=locked \
npm ci
COPY cmd/gateway/admin_frontend ./cmd/gateway/admin_frontend
COPY cmd/gateway/admin_static/index.html cmd/gateway/admin_static/app.css ./cmd/gateway/admin_static/
RUN npm run build:admin
FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
go mod download
COPY . .
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \
mkdir -p /out \
&& CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o /out/mc-gateway ./cmd/gateway
FROM alpine:3.22
WORKDIR /data
COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway
COPY --from=admin-frontend /src/cmd/gateway/admin_static /usr/share/mc-gateway/admin_static
ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3
ENV MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
EXPOSE 25565/tcp
CMD ["/usr/local/bin/mc-gateway"]

143
README.md
View File

@@ -1,2 +1,143 @@
# mc-gateway
This project is a Minecraft game server gateway. It allows you to access multiple game servers through different hostnames, using the same port.
一个简易的 Minecraft 网关,通过客户端握手里的 host 将流量转发到对应的后端 Minecraft 服务器。
## 启动
mc-gateway 默认不依赖配置文件。直接启动后会在 `25565` 端口同时提供 Minecraft TCP 转发入口和后台管理入口:
- Admin 页面:`/admin/`
- Admin API`/admin/api`
- SQLite 数据库:`mc-gateway.sqlite3`
首次启动时,如果用户表为空,可以通过 Admin 页面初始化管理员账号。也可以用 `MC_GATEWAY_ADMIN_PASSWORD` 在启动时创建默认管理员用户 `admin`
### 启动期环境变量
| 环境变量 | 默认值 | 说明 |
| --- | --- | --- |
| `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 |
| `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
| `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 |
| `MC_GATEWAY_ADMIN_STATIC_DIR` | `cmd/gateway/admin_static` | Admin 前端静态文件目录Docker 镜像中为 `/usr/share/mc-gateway/admin_static` |
| `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 |
| `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
服务启停、KCP/QUIC/WebSocket 参数、用户、权限和路由都通过后台管理写入 SQLite不再使用 `config.toml` 作为启动配置或路由来源。
### Docker Compose
生产部署只需要 `compose.yaml`,可以直接用 Docker Compose 拉取已发布镜像并启动:
```sh
docker compose up -d
```
源码目录中包含 `compose.override.yaml`Docker Compose 会自动加载它,因此本地构建测试仍然可以直接运行:
```sh
docker compose build
```
默认使用 host network在宿主机 `25565/tcp` 提供 Minecraft TCP 转发入口和后台管理入口,后台地址为:
```text
http://<host>:25565/admin/
```
Compose 使用本地 `./data` 目录持久化 SQLite 数据库和 WAL 文件,不挂载旧 `config.toml`。首次启动可以在后台页面初始化管理员,也可以通过 `.env` 预置默认管理员 `admin` 的密码:
```env
MC_GATEWAY_ADMIN_PASSWORD=change-me
```
常用可选项:
```env
MC_GATEWAY_TCP_ADMIN_PORT=25565
MC_GATEWAY_ADMIN_PATH=/admin/
MC_GATEWAY_ADMIN_API_PREFIX=/admin/api
MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
MC_GATEWAY_DB=/data/mc-gateway.sqlite3
```
### Admin 前端开发
Admin 前端源码位于 `cmd/gateway/admin_frontend/src`,使用 TypeScript 拆分为原生 ES modules。构建产物输出到 `cmd/gateway/admin_static/js`Go 服务不会 embed 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录透传静态响应。
修改前端后运行:
```sh
npm install
npm run build:admin
```
`master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry
```text
ghcr.io/tursom/mc-gateway:latest
```
## 权限
后台管理内置三类角色:
| 角色 | 能力 |
| --- | --- |
| 管理员 | 管理用户、服务、路由和审计日志 |
| 成员 | 查看状态,管理路由 |
| 游客 | 查看当前路由 |
## 路由
路由记录存储在 SQLite 中,后台修改后会刷新内存快照。默认 fallback 路由的 host 为 `default`
### upstream
路由上游支持多种协议。TCP 上游直接填写地址,其他协议在地址前加协议前缀:
| 协议 | 前缀 | 说明 |
| --- | --- | --- |
| tcp | 无 | 原始 TCP 连接 |
| kcp | `kcp://` | 使用 KCP 协议连接到服务器 |
| quic | `quic://` | 使用 QUIC 协议连接到服务器 |
| haproxy | `haproxy://` | 使用 HAProxy 协议连接到服务器 |
HAProxy 协议头会保存客户端真实 IP适合需要在后端服务端获取真实客户端 IP 的场景。
## 可选服务
TCP/Admin listener 是基础入口默认启用。KCP、QUIC、WebSocket 默认禁用,可以在后台管理中启用并配置端口和参数;配置修改后第一版按重启后生效处理。
| 服务 | 默认端口 | 说明 |
| --- | --- | --- |
| TCP/Admin | `25565` | Minecraft TCP 转发和后台管理共享入口 |
| KCP | `25565` | 可选 KCP 入口 |
| QUIC | `25565` | 可选 QUIC 入口 |
| WebSocket | `25566` | 可选 WebSocket 入口 |
## 日志
默认日志级别为 `info`,输出到标准输出。日志文件重开逻辑支持 logrotate 场景:
```logrotate
/var/log/mc-gateway.log {
copytruncate
daily
missingok
rotate 14
compress
compresscmd /usr/bin/zstd
compressext .zst
compressoptions -T0 --long
uncompresscmd /usr/bin/unzstd
notifempty
delaycompress
dateext
postrotate
if [ -f /dev/shm/mc-gateway.pid ]; then
kill -SIGHUP $(cat /dev/shm/mc-gateway.pid)
fi
endscript
}
```

62
cmd/gateway/admin_api.go Normal file
View File

@@ -0,0 +1,62 @@
// cmd/gateway/admin_api.go 组装 Admin API 的共享依赖,并提供嵌入式控制台使用的顶层 HTTP 路由。
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func newAdminAPIHandler() http.HandlerFunc {
// Admin API 的路径解析放在 internal/adminhttp 中,主包只提供各业务 handler。
// 这样测试可以复用同一套路由表,而不会依赖真实监听器。
return adminhttp.NewAPIHandler(adminStartup.AdminAPIPrefix, adminhttp.APIHandlers{
SetupStatus: handleAdminSetupStatus,
Setup: handleAdminSetup,
Login: handleAdminLogin,
Logout: handleAdminLogout,
Me: handleAdminMe,
Status: handleAdminStatus,
RoutesList: handleAdminRoutesList,
RouteItem: handleAdminRouteItem,
ServicesList: handleAdminServicesList,
ServiceItem: handleAdminServiceItem,
Metrics: handleAdminMetrics,
UsersList: handleAdminUsersList,
UsersCreate: handleAdminUsersCreate,
UserItem: handleAdminUserItem,
AuditLogs: handleAdminAuditLogs,
// 插件相关接口数量较多,统一在这里接入,确保嵌入式 UI 和远程 CLI
// 看到的是同一套 Admin API 行为。
PluginArtifacts: handleAdminPluginArtifacts,
PluginArtifact: handleAdminPluginArtifact,
PluginSources: handleAdminPluginSources,
PluginBuilds: handleAdminPluginBuilds,
PluginBuild: handleAdminPluginBuild,
PluginGC: handleAdminPluginGC,
PluginOperationsGC: handleAdminPluginOperationsGC,
PluginsList: handleAdminPluginsList,
PluginItem: handleAdminPluginItem,
PluginAction: handleAdminPluginAction,
PluginConfig: handleAdminPluginConfig,
PluginSecrets: handleAdminPluginSecrets,
PluginRollback: handleAdminPluginRollback,
PluginOperations: handleAdminPluginOperations,
PluginDraining: handleAdminPluginDraining,
PluginDispatch: handleAdminPluginDispatchPlan,
PluginGovernance: handleAdminPluginGovernance,
PluginAdvisories: handleAdminPluginAdvisories,
PluginDiagnostics: handleAdminPluginDiagnostics,
PluginService: handleAdminPluginService,
PluginRepositories: handleAdminPluginRepositories,
PluginSupplyChain: handleAdminPluginSupplyChain,
PluginInstrumentation: handleAdminPluginInstrumentation,
})
}

View File

@@ -0,0 +1,565 @@
// cmd/gateway/admin_api_test.go 包含用于约束 admin api 行为的测试。
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
func TestAdminSetupLoginAndPermissions(t *testing.T) {
handler := newAdminTestHandler(t)
resp := adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("GET setup status = %d, want %d", resp.Code, http.StatusOK)
}
if got := adminTestJSON(t, resp)["required"]; got != true {
t.Fatalf("setup required = %v, want true", got)
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin",
"password": "secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("POST setup status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin2",
"password": "secret",
})
if resp.Code != http.StatusConflict {
t.Fatalf("repeat setup status = %d, want %d", resp.Code, http.StatusConflict)
}
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("admin status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "guest",
"role": "guest",
"password": "guest-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create guest status = %d, body=%s", resp.Code, resp.Body.String())
}
guestToken := adminTestLogin(t, handler, "guest", "guest-secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/routes", guestToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("guest routes status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", guestToken, map[string]any{
"upstream": "127.0.0.1:25565",
"enabled": true,
})
if resp.Code != http.StatusForbidden {
t.Fatalf("guest route write status = %d, want %d", resp.Code, http.StatusForbidden)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/users", guestToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("guest users status = %d, want %d", resp.Code, http.StatusForbidden)
}
}
func TestAdminRoutesRefreshSnapshot(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
token := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/routes/play.example", token, map[string]any{
"upstream": "127.0.0.1:25565",
"enabled": true,
"note": "primary",
})
if resp.Code != http.StatusOK {
t.Fatalf("route upsert status = %d, body=%s", resp.Code, resp.Body.String())
}
upstream, ok := lookupRoute("play.example")
if !ok || upstream != "127.0.0.1:25565" {
t.Fatalf("lookupRoute() = %q, %v; want route", upstream, ok)
}
resp = adminTestRequest(t, handler, http.MethodDelete, "/admin/api/routes/play.example", token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("route delete status = %d, body=%s", resp.Code, resp.Body.String())
}
if upstream, ok := lookupRoute("play.example"); ok || upstream != "" {
t.Fatalf("lookupRoute() after delete = %q, %v; want miss", upstream, ok)
}
}
func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
t.Cleanup(saveGatewayState(t))
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvPath, "/ops")
t.Setenv(adminEnvAPIPrefix, "/ops/api")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
handler := newGatewayHTTPHandler()
resp := adminTestRequest(t, handler, http.MethodGet, "/ops", "", nil)
if resp.Code != http.StatusMovedPermanently {
t.Fatalf("admin path redirect status = %d, want %d", resp.Code, http.StatusMovedPermanently)
}
if got := resp.Header().Get("Location"); got != "/ops/" {
t.Fatalf("admin path redirect location = %q, want /ops/", got)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
t.Fatalf("custom admin page does not reference runtime config: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/config.js", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom admin config status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
t.Fatalf("custom admin config body = %q", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom setup status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/setup", "", nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("default setup status = %d, want %d", resp.Code, http.StatusNotFound)
}
}
func TestAdminServiceUpdateMarksRestartRequired(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
token := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPut, "/admin/api/services/kcp", token, map[string]any{
"enabled": true,
"port": 25570,
"options": map[string]any{
"data_shards": 12,
"parity_shards": 4,
},
})
if resp.Code != http.StatusOK {
t.Fatalf("service update status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/services", token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("services status = %d, body=%s", resp.Code, resp.Body.String())
}
body := adminTestJSON(t, resp)
services := body["services"].([]any)
var found map[string]any
for _, item := range services {
service := item.(map[string]any)
if service["name"] == "kcp" {
found = service
break
}
}
if found == nil {
t.Fatal("kcp service not found")
}
if found["enabled"] != true || found["restart_required"] != true || found["running"] != false {
t.Fatalf("kcp service = %#v, want enabled restart_required and not running", found)
}
}
func TestAdminUserPatchInvalidatesExistingSession(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "member",
"role": "member",
"password": "member-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
}
memberToken := adminTestLogin(t, handler, "member", "member-secret")
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("member status before patch = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPatch, "/admin/api/users/member", adminToken, map[string]any{
"role": "guest",
})
if resp.Code != http.StatusOK {
t.Fatalf("patch member status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/status", memberToken, nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("old member token status = %d, want %d", resp.Code, http.StatusUnauthorized)
}
}
func TestAdminPluginPhase4API(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: adminDB,
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
Adapter: gatewayTestPluginAdapter{},
})
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "member",
"role": "member",
"password": "member-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
}
memberToken := adminTestLogin(t, handler, "member", "member-secret")
artifact := uploadGatewayPhase4Artifact(t, "phase4-plugin")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase4-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{"token":"old","host":"a"}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins", memberToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("member plugins list status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", memberToken, map[string]any{
"name": "api_token",
"value": "member-secret-value",
})
if resp.Code != http.StatusForbidden {
t.Fatalf("member secret write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/secrets", adminToken, map[string]any{
"artifact_id": artifact.ID,
"name": "api_token",
"value": "super-secret-value",
"reload_required": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("secret write status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "super-secret-value") {
t.Fatalf("secret response leaked value: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/config/dry-run", adminToken, map[string]any{
"artifact_id": artifact.ID,
"config_json": `{"token":"new","host":"b"}`,
})
if resp.Code != http.StatusOK {
t.Fatalf("config dry-run status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
t.Fatalf("dry-run leaked sensitive value: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPut, "/admin/api/plugins/phase4-plugin/config", adminToken, map[string]any{
"artifact_id": artifact.ID,
"config_json": `{"token":"new","host":"b"}`,
})
if resp.Code != http.StatusOK {
t.Fatalf("config update status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("snapshots status = %d, body=%s", resp.Code, resp.Body.String())
}
snapshotID := firstSnapshotID(t, resp)
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase4-plugin/config/snapshots/"+strconv.FormatInt(snapshotID, 10)+"/diff", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("snapshot diff status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "new") || strings.Contains(resp.Body.String(), "old") {
t.Fatalf("snapshot diff leaked sensitive value: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase4-plugin/rollback/config", adminToken, map[string]any{
"snapshot_id": snapshotID,
})
if resp.Code != http.StatusOK {
t.Fatalf("config rollback status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), "super-secret-value") {
t.Fatalf("audit leaked secret value: %s", resp.Body.String())
}
}
func TestAdminPluginPhase5GovernanceAPI(t *testing.T) {
handler := newAdminTestHandlerWithAdmin(t)
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: adminDB,
ArtifactRoot: filepath.Join(filepath.Dir(adminDBPath), "plugins", "artifacts"),
Adapter: gatewayTestPluginAdapter{},
})
adminToken := adminTestLogin(t, handler, "admin", "secret")
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/users", adminToken, map[string]any{
"username": "member",
"role": "member",
"password": "member-secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("create member status = %d, body=%s", resp.Code, resp.Body.String())
}
memberToken := adminTestLogin(t, handler, "member", "member-secret")
artifact := uploadGatewayPhase5ProtocolProxyArtifact(t, "phase5-proxy")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "phase5-proxy", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugins/phase5-proxy/governance?profile=prod", memberToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("member governance status = %d, body=%s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "review_required") {
t.Fatalf("governance status body = %s, want review_required", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", memberToken, map[string]any{
"artifact_id": artifact.ID,
"profile": pluginmanager.PolicyProfileProd,
})
if resp.Code != http.StatusForbidden {
t.Fatalf("member review write status = %d, want forbidden; body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/review", adminToken, map[string]any{
"artifact_id": artifact.ID,
"profile": pluginmanager.PolicyProfileProd,
"decision": pluginmanager.ReviewDecisionApproved,
"notes": "phase 5 approval",
})
if resp.Code != http.StatusOK {
t.Fatalf("admin review write status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/preflight", adminToken, map[string]any{
"artifact_id": artifact.ID,
"profile": pluginmanager.PolicyProfileProd,
"action": pluginmanager.GovernanceActionEnable,
})
if resp.Code != http.StatusOK {
t.Fatalf("preflight status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/benchmark", adminToken, map[string]any{
"artifact_id": artifact.ID,
"profile": pluginmanager.PolicyProfileProd,
"benchmark_profile": "release",
"p95_ms": 10,
"p99_ms": 20,
"baseline_diff": 0.25,
"active_proxy_capacity": 100,
})
if resp.Code != http.StatusOK {
t.Fatalf("benchmark status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugins/phase5-proxy/governance/override", adminToken, map[string]any{
"artifact_id": artifact.ID,
"profile": pluginmanager.PolicyProfileProd,
"action": pluginmanager.GovernanceActionEnable,
"reason": "accepted warning for rollout",
"ttl_seconds": 3600,
})
if resp.Code != http.StatusOK {
t.Fatalf("override status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodPost, "/admin/api/plugin-advisories", adminToken, map[string]any{
"advisory_id": "MCG-2026-ADMIN",
"status": pluginmanager.AdvisoryStatusRevoked,
"action": pluginmanager.AdvisoryActionRevoke,
"artifact_sha256": artifact.SHA256,
})
if resp.Code != http.StatusOK {
t.Fatalf("advisory status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/plugin-advisories?plugin_id=phase5-proxy", memberToken, nil)
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "MCG-2026-ADMIN") {
t.Fatalf("member advisory read status = %d, body=%s", resp.Code, resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/admin/api/audit-logs", adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("audit status = %d, body=%s", resp.Code, resp.Body.String())
}
for _, action := range []string{"plugin_governance_review", "plugin_governance_override", "plugin_governance_advisory"} {
if !strings.Contains(resp.Body.String(), action) {
t.Fatalf("audit body missing %s: %s", action, resp.Body.String())
}
}
}
func uploadGatewayPhase4Artifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
t.Helper()
var manifest pluginmanager.Manifest
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
t.Fatalf("Unmarshal manifest error = %v", err)
}
manifest.ConfigSchema = json.RawMessage(`{"type":"object","properties":{"token":{"type":"string","sensitive":true},"host":{"type":"string"}}}`)
manifest.Secrets = []pluginmanager.SecretSpec{{
Name: "api_token",
Required: false,
Type: "api_token",
Rotation: pluginmanager.SecretRotation{
Reload: "reload_required",
},
}}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
"manifest.json": manifestBytes,
"plugin.so": []byte("fake plugin bytes " + pluginID),
}),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func uploadGatewayPhase5ProtocolProxyArtifact(t *testing.T, pluginID string) pluginmanager.ArtifactRecord {
t.Helper()
var manifest pluginmanager.Manifest
if err := json.Unmarshal(gatewayTestManifestWithCapabilities(t, pluginID, gatewayProtocolProxyCapabilities()), &manifest); err != nil {
t.Fatalf("Unmarshal manifest error = %v", err)
}
manifest.RuntimeLimits = pluginmanager.RuntimeLimits{HandlerTimeoutMS: 3000}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
artifact, err := pluginsManager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
"manifest.json": manifestBytes,
"plugin.so": []byte("fake plugin bytes " + pluginID),
}),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func newAdminTestHandler(t *testing.T) http.Handler {
t.Helper()
t.Cleanup(saveGatewayState(t))
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
return newGatewayHTTPHandler()
}
func newAdminTestHandlerWithAdmin(t *testing.T) http.Handler {
t.Helper()
handler := newAdminTestHandler(t)
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/setup", "", map[string]any{
"username": "admin",
"password": "secret",
})
if resp.Code != http.StatusCreated {
t.Fatalf("setup status = %d, body=%s", resp.Code, resp.Body.String())
}
return handler
}
func adminTestLogin(t *testing.T, handler http.Handler, username, password string) string {
t.Helper()
resp := adminTestRequest(t, handler, http.MethodPost, "/admin/api/auth/login", "", map[string]any{
"username": username,
"password": password,
})
if resp.Code != http.StatusOK {
t.Fatalf("login status = %d, body=%s", resp.Code, resp.Body.String())
}
body := adminTestJSON(t, resp)
token, ok := body["token"].(string)
if !ok || token == "" {
t.Fatalf("login token = %#v", body["token"])
}
return token
}
func adminTestRequest(t *testing.T, handler http.Handler, method, target, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var payload bytes.Buffer
if body != nil {
if err := json.NewEncoder(&payload).Encode(body); err != nil {
t.Fatalf("Encode() error = %v", err)
}
}
req := httptest.NewRequest(method, target, &payload)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req)
return resp
}
func adminTestJSON(t *testing.T, resp *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", resp.Body.String(), err)
}
return body
}
func firstSnapshotID(t *testing.T, resp *httptest.ResponseRecorder) int64 {
t.Helper()
body := adminTestJSON(t, resp)
snapshots, ok := body["snapshots"].([]any)
if !ok || len(snapshots) == 0 {
t.Fatalf("snapshots = %#v, want at least one", body["snapshots"])
}
first, ok := snapshots[0].(map[string]any)
if !ok {
t.Fatalf("first snapshot = %#v", snapshots[0])
}
id, ok := first["id"].(float64)
if !ok || id <= 0 {
t.Fatalf("snapshot id = %#v", first["id"])
}
return int64(id)
}

View File

@@ -0,0 +1,21 @@
// cmd/gateway/admin_audit.go 把 HTTP 请求上下文转换为持久化审计记录,用于追踪管理端变更。
package main
import (
"context"
"github.com/tursom/mc-gateway/internal/adminaudit"
)
func recordAudit(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string) {
_ = adminaudit.NewRepository(adminDB).Record(ctx, actor, sourceIP, action, targetType, targetID, success, message)
}
func recordAuditMetadata(ctx context.Context, actor, sourceIP, action, targetType, targetID string, success bool, message string, metadata any) {
_ = adminaudit.NewRepository(adminDB).RecordWithMetadata(ctx, actor, sourceIP, action, targetType, targetID, success, message, metadata)
}
func listAuditLogs(ctx context.Context) ([]adminaudit.Record, error) {
return adminaudit.NewRepository(adminDB).List(ctx, adminaudit.DefaultListLimit)
}

View File

@@ -0,0 +1,115 @@
// cmd/gateway/admin_auth_handlers.go 处理初始化、登录、登出和当前会话查询等嵌入式管理端认证接口。
package main
import (
"net/http"
"os"
"strings"
"time"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminuser"
)
func handleAdminSetupStatus(w http.ResponseWriter, r *http.Request) {
empty, err := usersTableEmpty(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"required": empty})
}
func handleAdminSetup(w http.ResponseWriter, r *http.Request) {
var req adminhttp.SetupRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
if strings.TrimSpace(req.Username) == "" {
req.Username = "admin"
}
err := createInitialAdmin(r.Context(), req.Username, req.Password)
if err != nil {
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, false, err.Error())
status := http.StatusBadRequest
if strings.Contains(err.Error(), "already") {
status = http.StatusConflict
}
adminhttp.WriteAPIError(w, status, err.Error())
return
}
recordAudit(r.Context(), "setup", adminhttp.RequestSourceIP(r), "setup", "user", req.Username, true, "created initial admin")
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
}
func handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var req adminhttp.LoginRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
user, err := authenticateUser(r.Context(), req.Username, req.Password)
if err != nil {
recordAudit(r.Context(), req.Username, adminhttp.RequestSourceIP(r), "login", "user", req.Username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusUnauthorized, err.Error())
return
}
session, err := createSession(user.Username, user.Role)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
recordAudit(r.Context(), user.Username, adminhttp.RequestSourceIP(r), "login", "user", user.Username, true, "login success")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"token": session.Token,
"expires_at": session.ExpiresAt.Unix(),
"user": user,
})
}
func handleAdminLogout(w http.ResponseWriter, r *http.Request) {
session, ok := requireSession(w, r)
if !ok {
return
}
deleteSession(session.Token)
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "logout", "session", session.Username, true, "logout success")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func handleAdminMe(w http.ResponseWriter, r *http.Request) {
session, ok := requireSession(w, r)
if !ok {
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"username": session.Username,
"role": session.Role,
"expires_at": session.ExpiresAt.Unix(),
"permissions": adminuser.Permissions(session.Role),
})
}
func handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
services, err := listServiceConfigs(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{
"pid": os.Getpid(),
"uptime_seconds": int64(time.Since(processStartAt).Seconds()),
"db_path": adminDBPath,
"tcp_admin_port": adminStartup.TCPAdminPort,
"admin_path": adminStartup.AdminPath,
"admin_api_prefix": adminStartup.AdminAPIPrefix,
"services": services,
})
}

View File

@@ -0,0 +1,10 @@
// cmd/gateway/admin_frontend/src/alerts.ts 集中处理告警展示,让异步界面流程可以一致地清空或显示错误。
import { el } from "./dom.js";
import { localizeMessage } from "./i18n.js";
export function showAlert(message: unknown): void {
const box = el("alert");
box.textContent = localizeMessage(message);
box.classList.toggle("hidden", !message);
}

View File

@@ -0,0 +1,49 @@
// cmd/gateway/admin_frontend/src/api.ts 封装 fetch统一处理 Admin API 前缀、令牌、JSON 编码和错误返回。
import { state } from "./state.js";
interface APIOptions {
method?: string;
body?: unknown;
formData?: FormData;
}
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
if (options.body !== undefined && !options.formData) {
headers["Content-Type"] = "application/json";
}
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const res = await fetch(state.apiBase + path, {
method: options.method || "GET",
headers,
body: options.formData || (options.body === undefined ? undefined : JSON.stringify(options.body)),
});
let data: unknown = {};
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!res.ok) {
throw new Error(apiErrorMessage(data, res.statusText));
}
return data as T;
}
function apiErrorMessage(data: unknown, fallback: string): string {
if (data && typeof data === "object" && "error" in data) {
const error = (data as { error?: unknown }).error;
if (typeof error === "string" && error) {
return error;
}
}
return fallback;
}

View File

@@ -0,0 +1,15 @@
// cmd/gateway/admin_frontend/src/config.ts 读取嵌入式管理端 HTML 壳注入的运行时配置。
import type { RuntimeConfig } from "./types.js";
declare global {
interface Window {
MCGatewayAdmin?: Partial<RuntimeConfig>;
}
}
export function runtimeConfig(): RuntimeConfig {
return {
apiPrefix: window.MCGatewayAdmin?.apiPrefix || "/admin/api",
};
}

View File

@@ -0,0 +1,55 @@
// cmd/gateway/admin_frontend/src/dom.ts 集中 DOM 辅助方法,包括查询、转义、徽标、去抖和表单取值。
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) {
throw new Error(`missing element #${id}`);
}
return node as T;
}
export function escapeHTML(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[ch] || ch));
}
export function escapeAttr(value: unknown): string {
return escapeHTML(value).replace(/`/g, "&#96;");
}
export function stat(label: string, value: unknown): string {
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
}
export function badge(text: string, off = false): string {
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
}
export function debounce<T extends (...args: unknown[]) => void>(fn: T, wait: number): T {
let id = 0;
return ((...args: Parameters<T>) => {
clearTimeout(id);
id = window.setTimeout(() => fn(...args), wait);
}) as T;
}
export function getFormInput(form: HTMLFormElement, name: string): HTMLInputElement {
const field = form.elements.namedItem(name);
if (!(field instanceof HTMLInputElement)) {
throw new Error(`missing input ${name}`);
}
return field;
}
export function getFormSelect(form: HTMLFormElement, name: string): HTMLSelectElement {
const field = form.elements.namedItem(name);
if (!(field instanceof HTMLSelectElement)) {
throw new Error(`missing select ${name}`);
}
return field;
}

View File

@@ -0,0 +1,323 @@
// cmd/gateway/admin_frontend/src/i18n.ts 保存嵌入式管理端翻译字典,并提供语言切换辅助方法。
import { el } from "./dom.js";
import { languageStorageKey, state } from "./state.js";
const defaultLanguage = "en";
const translations = {
en: {
activeConnections: "Active",
activeUser: "Active",
actions: "Actions",
actor: "Actor",
active: "Active",
adminSubtitle: "Admin",
artifact: "Artifact",
audit: "Audit",
cancel: "Cancel",
createAdmin: "Create admin",
dataShards: "Data shards",
delete: "Delete",
deleteDefaultRouteConfirm: "Delete default route?",
deleteUserConfirm: "Delete user {username}?",
desired: "Desired",
desiredArtifact: "Desired artifact",
dialErrors: "Dial errors",
disabled: "Disabled",
edit: "Edit",
enabled: "Enabled",
extensions: "Extensions",
failed: "Failed",
health: "Health",
host: "Host",
initialAdmin: "Initial admin",
language: "Language",
languageChinese: "中文",
languageEnglish: "English",
login: "Login",
logout: "Logout",
message: "Message",
metrics: "Metrics",
misses: "Misses",
name: "Name",
newRoute: "New route",
newUser: "New user",
noHits: "No hits",
note: "Note",
parityShards: "Parity shards",
password: "Password",
path: "Path",
port: "Port",
protocols: "Protocols",
pluginID: "Plugin ID",
plugins: "Plugins",
priority: "Priority",
refresh: "Refresh",
restart: "Restart",
restartRequired: "restart required",
result: "Result",
role: "Role",
roleAdmin: "Admin",
roleGuest: "Guest",
roleMember: "Member",
route: "Route",
routeHits: "Route hits",
routeSearch: "Search host, upstream, note",
routes: "Routes",
runtime: "Runtime",
save: "Save",
services: "Services",
setupSubtitle: "Setup",
success: "Success",
target: "Target",
time: "Time",
total: "Total",
upstream: "Upstream",
uploadPlugin: "Upload plugin",
uptime: "Uptime",
user: "User",
username: "Username",
users: "Users",
version: "Version",
},
zh: {
activeConnections: "活跃连接",
activeUser: "启用",
actions: "操作",
actor: "操作者",
active: "当前",
adminSubtitle: "管理后台",
artifact: "Artifact",
audit: "审计",
cancel: "取消",
createAdmin: "创建管理员",
dataShards: "数据分片",
delete: "删除",
deleteDefaultRouteConfirm: "确认删除默认路由?",
deleteUserConfirm: "确认删除用户 {username}",
desired: "期望",
desiredArtifact: "期望 Artifact",
dialErrors: "连接上游失败",
disabled: "禁用",
edit: "编辑",
enabled: "启用",
extensions: "扩展点",
failed: "失败",
health: "健康",
host: "主机",
initialAdmin: "初始化管理员",
language: "语言",
languageChinese: "中文",
languageEnglish: "English",
login: "登录",
logout: "退出登录",
message: "消息",
metrics: "指标",
misses: "未命中",
name: "名称",
newRoute: "新建路由",
newUser: "新建用户",
noHits: "暂无命中",
note: "备注",
parityShards: "校验分片",
password: "密码",
path: "路径",
port: "端口",
protocols: "协议",
pluginID: "插件 ID",
plugins: "插件",
priority: "优先级",
refresh: "刷新",
restart: "重启",
restartRequired: "需要重启",
result: "结果",
role: "角色",
roleAdmin: "管理员",
roleGuest: "访客",
roleMember: "成员",
route: "路由",
routeHits: "路由命中",
routeSearch: "搜索主机、上游、备注",
routes: "路由",
runtime: "运行时",
save: "保存",
services: "服务",
setupSubtitle: "初始化",
success: "成功",
target: "目标",
time: "时间",
total: "总数",
upstream: "上游",
uploadPlugin: "上传插件",
uptime: "运行时间",
user: "用户",
username: "用户名",
users: "用户",
version: "版本",
},
} as const;
type TranslationKey = keyof typeof translations.en;
type Language = keyof typeof translations;
const localizedMessages: Partial<Record<Language, Record<string, string>>> = {
zh: {
"admin API prefix cannot be under static asset path": "管理 API 前缀不能位于静态资源路径下",
"admin API prefix cannot equal admin page path": "管理 API 前缀不能等于管理页面路径",
"cannot delete the last enabled admin": "不能删除最后一个启用的管理员",
"cannot remove the last enabled admin": "不能移除最后一个启用的管理员",
"created initial admin": "已创建初始管理员",
"host is required": "主机不能为空",
"host must not contain /": "主机不能包含 /",
"host must not contain whitespace": "主机不能包含空白字符",
"initial admin has already been created": "初始管理员已创建",
"invalid JSON": "JSON 无效",
"invalid path segment": "路径片段无效",
"invalid username or password": "用户名或密码无效",
"login required": "需要登录",
"login success": "登录成功",
"logout success": "退出登录成功",
"method not allowed": "方法不允许",
"not found": "未找到",
"password hash is required": "密码哈希不能为空",
"password is required": "密码不能为空",
"permission denied": "没有权限",
"port must be an integer from 1 to 65535": "端口必须是 1 到 65535 之间的整数",
"restart is not implemented": "暂未实现重启",
"route deleted": "路由已删除",
"route saved": "路由已保存",
"service restart is not implemented; restart the gateway process": "暂未实现服务重启;请重启网关进程",
"service saved": "服务已保存",
"tcp_admin cannot be disabled": "tcp_admin 不能被禁用",
"upstream host is required": "上游主机不能为空",
"upstream is required": "上游不能为空",
"upstream must be host:port": "上游必须是 host:port 格式",
"upstream port must be an integer from 1 to 65535": "上游端口必须是 1 到 65535 之间的整数",
"user created": "用户已创建",
"user deleted": "用户已删除",
"user is disabled": "用户已禁用",
"user not found": "用户不存在",
"user updated": "用户已更新",
"username is required": "用户名不能为空",
"username must not contain whitespace or /": "用户名不能包含空白字符或 /",
},
};
const localizedPrefixes: Partial<Record<Language, Array<[string, string]>>> = {
zh: [
["invalid JSON: ", "JSON 无效:"],
["invalid role ", "无效角色 "],
["unknown service ", "未知服务 "],
],
};
export const auditActions: Partial<Record<Language, Record<string, string>>> = {
zh: {
login: "登录",
logout: "退出登录",
route_delete: "删除路由",
route_upsert: "保存路由",
service_restart: "重启服务",
service_update: "更新服务",
setup: "初始化",
user_create: "创建用户",
user_delete: "删除用户",
user_patch: "更新用户",
},
};
export const targetTypes: Partial<Record<Language, Record<string, string>>> = {
zh: {
route: "路由",
service: "服务",
session: "会话",
user: "用户",
},
};
export function initializeLanguage(): void {
state.language = resolveInitialLanguage();
applyLanguage();
}
export function changeLanguage(next: string, rerender: () => void): void {
if (!isLanguage(next) || next === state.language) {
return;
}
state.language = next;
localStorage.setItem(languageStorageKey, next);
applyLanguage();
rerender();
}
export function applyLanguage(): void {
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
el<HTMLSelectElement>("languageSelect").value = state.language;
document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((node) => {
node.textContent = t(node.dataset.i18n as TranslationKey);
});
document.querySelectorAll<HTMLInputElement>("[data-i18n-placeholder]").forEach((node) => {
node.placeholder = t(node.dataset.i18nPlaceholder as TranslationKey);
});
if (!el("setupView").classList.contains("hidden")) {
setSubtitle("setupSubtitle");
} else if (!el("loginView").classList.contains("hidden")) {
setSubtitle("login");
} else {
setSubtitle("adminSubtitle");
}
}
export function setSubtitle(key: TranslationKey): void {
el("subtitle").textContent = t(key);
}
export function t(key: TranslationKey, values: Record<string, string> = {}): string {
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
let text: string = translations[lang][key] || translations[defaultLanguage][key] || key;
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, value);
}
return text;
}
export function localizeMessage(message: unknown): string {
if (!message) {
return "";
}
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
const text = String(message);
const messages = localizedMessages[lang] || {};
if (messages[text]) {
return messages[text];
}
const prefixes = localizedPrefixes[lang] || [];
for (const [source, replacement] of prefixes) {
if (text.startsWith(source)) {
return replacement + text.slice(source.length);
}
}
return text;
}
export function formatAuditAction(action: string): string {
return auditActions[state.language as Language]?.[action] || action;
}
export function formatTargetType(targetType: string): string {
return targetTypes[state.language as Language]?.[targetType] || targetType;
}
function resolveInitialLanguage(): Language {
const stored = localStorage.getItem(languageStorageKey);
if (isLanguage(stored)) {
return stored;
}
const browserLanguage = (navigator.language || "").toLowerCase();
return browserLanguage.startsWith("zh") ? "zh" : defaultLanguage;
}
function isLanguage(value: unknown): value is Language {
return typeof value === "string" && value in translations;
}

View File

@@ -0,0 +1,210 @@
// cmd/gateway/admin_frontend/src/main.ts 启动嵌入式管理端,选择初始化/登录/应用视图,并协调按角色加载数据。
import { api } from "./api.js";
import { showAlert } from "./alerts.js";
import { runtimeConfig } from "./config.js";
import { debounce, el } from "./dom.js";
import { changeLanguage, initializeLanguage, setSubtitle } from "./i18n.js";
import { isAdmin, isMember, renderSessionUser } from "./session.js";
import { setToken, state } from "./state.js";
import type { LoginResponse, SetupStatus, User } from "./types.js";
import { loadAudit } from "./views/audit.js";
import { loadMetrics } from "./views/metrics.js";
import { bindPluginEvents, loadPlugins, renderPluginDetail, renderPlugins } from "./views/plugins.js";
import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js";
import { loadServices, renderServices } from "./views/services.js";
import { loadStatus } from "./views/status.js";
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
async function boot(): Promise<void> {
// API 前缀由后端嵌入到 HTML 中,前端启动时先读取它,避免部署在子路径时写死地址。
state.apiBase = runtimeConfig().apiPrefix;
initializeLanguage();
bindEvents();
try {
const setup = await api<SetupStatus>("/setup");
if (setup.required) {
// 没有任何管理账号时只展示初始化界面,不尝试加载其他运行态数据。
setView("setupView");
setSubtitle("setupSubtitle");
return;
}
} catch (err) {
showAlert((err as Error).message);
}
if (!state.token) {
// token 保存在本地状态中;没有 token 时直接进入登录视图。
setView("loginView");
setSubtitle("login");
return;
}
try {
state.user = await api<User>("/me");
await showApp();
} catch {
// token 失效时清空本地状态,避免后续 API 调用持续带着过期凭证。
setToken("");
setView("loginView");
setSubtitle("login");
}
}
function bindEvents(): void {
// 所有顶层事件在启动时绑定一次,视图重渲染只更新内容区域。
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
});
el<HTMLFormElement>("setupForm").addEventListener("submit", submitSetup);
el<HTMLFormElement>("loginForm").addEventListener("submit", submitLogin);
el("logoutBtn").addEventListener("click", logout);
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
el("newUserBtn").addEventListener("click", () => openUserDialog());
el<HTMLFormElement>("routeForm").addEventListener("submit", saveRoute);
el<HTMLFormElement>("userForm").addEventListener("submit", saveUser);
bindPluginEvents();
document.querySelectorAll<HTMLButtonElement>("[data-close]").forEach((button) => {
button.addEventListener("click", () => button.closest("dialog")?.close());
});
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
button.addEventListener("click", () => {
if (button.dataset.tab) {
selectTab(button.dataset.tab);
}
});
});
}
async function submitSetup(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
// 初始化只创建首个管理员账号,创建成功后仍要求用户走登录流程获取会话 token。
await api("/setup", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
showAlert("");
setView("loginView");
setSubtitle("login");
} catch (err) {
showAlert((err as Error).message);
}
}
async function submitLogin(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
// 登录成功后立即保存 token 和用户信息,再统一进入应用态加载流程。
const data = await api<LoginResponse>("/auth/login", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
setToken(data.token);
state.user = data.user;
showAlert("");
await showApp();
} catch (err) {
showAlert((err as Error).message);
}
}
async function logout(): Promise<void> {
try {
await api("/auth/logout", { method: "POST", body: {} });
} catch {
// 服务端登出失败不阻塞本地清理,避免用户卡在失效会话上。
}
setToken("");
state.user = null;
setView("loginView");
setSubtitle("login");
renderSessionUser();
el("logoutBtn").classList.add("hidden");
}
async function showApp(): Promise<void> {
// 路由列表是成员和管理员都可见的基础视图,因此先加载它。
setView("appView");
setSubtitle("adminSubtitle");
renderSessionUser();
el("logoutBtn").classList.remove("hidden");
applyRoleVisibility();
await loadRoutes();
if (isMember()) {
// 成员权限可以查看运行态、服务、指标和插件,但不能管理用户与审计。
await loadStatus();
await loadServices();
await loadMetrics();
await loadPlugins();
}
if (isAdmin()) {
// 管理员专属数据放在最后加载,减少普通成员的无权限请求。
await loadUsers();
await loadAudit();
}
}
function applyRoleVisibility(): void {
const member = isMember();
const admin = isAdmin();
// 角色控制只隐藏入口;服务端仍会按 token 做权限校验。
el("statusGrid").classList.toggle("hidden", !member);
el("newRouteBtn").classList.toggle("hidden", !member);
toggleTab("services", member);
toggleTab("plugins", member);
toggleTab("metrics", member);
toggleTab("users", admin);
toggleTab("audit", admin);
selectTab("routes");
}
function toggleTab(name: string, visible: boolean): void {
document.querySelector(`[data-tab="${name}"]`)?.classList.toggle("hidden", !visible);
}
function selectTab(name: string): void {
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === name);
});
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((panel) => {
panel.classList.add("hidden");
});
el(`${name}Tab`).classList.remove("hidden");
}
function setView(name: string): void {
for (const id of ["setupView", "loginView", "appView"]) {
el(id).classList.toggle("hidden", id !== name);
}
}
function rerenderCurrentView(): void {
// 切换语言后复用当前内存状态重绘静态文案,再刷新会随语言展示的远端数据。
renderSessionUser();
renderRoutes();
renderServices();
renderUsers();
renderPlugins();
renderPluginDetail();
if (isMember()) {
loadStatus();
loadMetrics();
loadPlugins();
}
if (isAdmin()) {
loadAudit();
}
}
boot();

View File

@@ -0,0 +1,28 @@
// cmd/gateway/admin_frontend/src/session.ts 渲染会话用户状态,并提供管理端界面使用的角色判断。
import { el } from "./dom.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import type { Role } from "./types.js";
export function isAdmin(): boolean {
return state.user?.role === "admin";
}
export function isMember(): boolean {
return state.user?.role === "admin" || state.user?.role === "member";
}
export function renderSessionUser(): void {
el("sessionUser").textContent = state.user ? `${state.user.username} (${formatRole(state.user.role)})` : "";
}
export function formatRole(role: Role | string): string {
const keys: Partial<Record<string, "roleAdmin" | "roleMember" | "roleGuest">> = {
admin: "roleAdmin",
member: "roleMember",
guest: "roleGuest",
};
const key = keys[role];
return key ? t(key) : role;
}

View File

@@ -0,0 +1,49 @@
// cmd/gateway/admin_frontend/src/state.ts 保存各视图模块共享的可变客户端状态。
import type { PluginArtifact, PluginBuild, PluginInstrumentation, PluginServiceStatus, PluginView, RouteRecord, ServiceRecord, User } from "./types.js";
export const tokenStorageKey = "mcGatewayAdminToken";
export const languageStorageKey = "mcGatewayAdminLanguage";
export interface AppState {
apiBase: string;
language: string;
token: string;
user: User | null;
routes: RouteRecord[];
services: ServiceRecord[];
users: User[];
plugins: PluginView[];
pluginArtifacts: PluginArtifact[];
pluginBuilds: PluginBuild[];
pluginService: PluginServiceStatus | null;
pluginInstrumentation: PluginInstrumentation[];
selectedPluginID: string;
selectedArtifactID: string;
}
export const state: AppState = {
apiBase: "/admin/api",
language: "",
token: sessionStorage.getItem(tokenStorageKey) || "",
user: null,
routes: [],
services: [],
users: [],
plugins: [],
pluginArtifacts: [],
pluginBuilds: [],
pluginService: null,
pluginInstrumentation: [],
selectedPluginID: "",
selectedArtifactID: "",
};
export function setToken(token: string): void {
state.token = token;
if (token) {
sessionStorage.setItem(tokenStorageKey, token);
} else {
sessionStorage.removeItem(tokenStorageKey);
}
}

View File

@@ -0,0 +1,272 @@
// cmd/gateway/admin_frontend/src/types.ts 声明 Admin API 返回并被各视图消费的 TypeScript 数据结构。
export type Role = "admin" | "member" | "guest";
export interface User {
username: string;
role: Role;
disabled: boolean;
permissions?: Record<string, boolean>;
}
export interface RouteRecord {
host: string;
upstream: string;
enabled: boolean;
note?: string;
}
export interface ServiceRecord {
name: string;
enabled: boolean;
port: number;
options?: Record<string, unknown>;
restart_required?: boolean;
}
export interface Metrics {
total_connections?: number;
active_connections?: number;
tcp_connections?: number;
websocket_connections?: number;
route_misses?: number;
upstream_dial_errors?: number;
route_hits?: Record<string, number>;
}
export interface AuditLog {
created_at: number;
actor: string;
action: string;
target_type: string;
target_id: string;
success: boolean;
message?: string;
}
export interface PluginArtifact {
id: string;
plugin_id: string;
version: string;
file_name?: string;
sha256: string;
package_sha256?: string;
artifact_type: string;
runtime_type: string;
status: string;
metadata_json?: string;
capabilities_summary_json?: string;
extension_points_json?: string;
api_version?: string;
go_version?: string;
go_os?: string;
go_arch?: string;
uploaded_by?: string;
error?: string;
created_at?: number;
updated_at?: number;
}
export interface PluginBuild {
id: number;
plugin_id: string;
source_id: string;
artifact_id: string;
status: string;
builder_type: string;
go_version?: string;
go_os?: string;
go_arch?: string;
source_sha256?: string;
artifact_sha256?: string;
log_summary?: string;
error?: string;
duration_ms?: number;
created_at?: number;
updated_at?: number;
}
export interface PluginSecret {
plugin_id: string;
name: string;
current_version: number;
previous_version: number;
reload_required: boolean;
hot_reload: boolean;
updated_by?: string;
updated_at?: number;
}
export interface PluginSnapshot {
id: number;
plugin_id: string;
artifact_id: string;
config_json: string;
desired_state: string;
priority: number;
desired_generation: number;
created_by?: string;
created_at?: number;
}
export interface PluginProxyConnection {
id: number;
plugin_id: string;
artifact_id: string;
handler_id: string;
started_at: number;
duration_ms: number;
draining: boolean;
}
export interface PluginServiceState {
desired_mode: string;
active_mode: string;
applied_at?: number;
restart_required: boolean;
live_migration?: string;
last_error?: string;
updated_by?: string;
updated_at?: number;
}
export interface PluginHostRuntimeSummary {
plugin_id: string;
artifact_id: string;
state: string;
drain_mode: string;
crash_loop: boolean;
crash_count: number;
last_error?: string;
}
export interface PluginServiceStatus {
service: PluginServiceState;
hosts?: PluginHostRuntimeSummary[];
}
export interface PluginInstrumentation {
id: number;
name: string;
version: string;
profile: string;
generated_diff_hash: string;
runbook_rollback: string;
status: string;
created_by?: string;
created_at?: number;
}
export interface GovernanceIssue {
code: string;
severity: string;
message: string;
plugin_id?: string;
artifact_id?: string;
details?: Record<string, unknown>;
}
export interface GovernanceDecision {
ok: boolean;
action: string;
profile: string;
risk_level: string;
policy_hash: string;
review_required: boolean;
warning_override_used: boolean;
issues?: GovernanceIssue[];
checks?: GovernanceIssue[];
}
export interface GovernanceStatus {
decision?: GovernanceDecision;
policy?: Record<string, unknown>;
reviews?: Record<string, unknown>[];
warning_overrides?: Record<string, unknown>[];
preflights?: Record<string, unknown>[];
benchmarks?: Record<string, unknown>[];
advisories?: Record<string, unknown>[];
conflicts?: {
ok: boolean;
issues?: GovernanceIssue[];
plan?: unknown;
};
}
export interface PluginView {
id: string;
name?: string;
version?: string;
artifact_type?: string;
runtime_type?: string;
desired_state: string;
runtime_state: string;
desired_artifact_id: string;
active_artifact_id: string;
loaded_artifact_id: string;
desired_artifact?: PluginArtifact;
active_artifact?: PluginArtifact;
loaded_artifact?: PluginArtifact;
extension_points?: string[];
priority: number;
scope?: unknown;
rollout?: unknown;
restart_required?: boolean;
health?: string;
last_error?: string;
runtime_summary?: Record<string, unknown>;
dispatch_summary?: unknown[];
extension_status?: Record<string, unknown>;
capabilities_summary?: Record<string, unknown>;
minecraft?: unknown;
config_json?: string;
config_schema?: Record<string, unknown>;
secrets?: PluginSecret[];
snapshots?: PluginSnapshot[];
builds?: PluginBuild[];
artifacts?: PluginArtifact[];
manifest?: Record<string, unknown>;
active_proxy_connections?: number;
proxy_connections?: PluginProxyConnection[];
governance?: GovernanceStatus;
governance_error?: string;
updated_at?: number;
}
export interface PluginOperations {
handlers?: Record<string, unknown>[];
builds?: Record<string, unknown>[];
events?: Record<string, unknown>[];
custom_metrics?: Record<string, unknown>[];
logs?: Record<string, unknown>[];
traces?: Record<string, unknown>[];
background_tasks?: Record<string, unknown>[];
plugin_data?: Record<string, unknown>[];
plugin_files?: Record<string, unknown>[];
external_dependencies?: Record<string, unknown>[];
gc?: Record<string, unknown>[];
event_queue?: Record<string, unknown>;
diagnostics?: Record<string, unknown>[];
}
export interface PluginDryRunResult {
ok: boolean;
restart_required: boolean;
hot_reload: boolean;
sensitive_paths: string[];
redacted_config_json: string;
redacted_diff_json: string;
}
export interface SetupStatus {
required: boolean;
}
export interface LoginResponse {
token: string;
user: User;
}
export interface RuntimeConfig {
apiPrefix: string;
}

View File

@@ -0,0 +1,29 @@
// cmd/gateway/admin_frontend/src/views/audit.ts 渲染管理员用于复核运行态变更的审计日志。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeHTML } from "../dom.js";
import { formatAuditAction, formatTargetType, localizeMessage, t } from "../i18n.js";
import type { AuditLog } from "../types.js";
interface AuditResponse {
audit_logs?: AuditLog[];
}
export async function loadAudit(): Promise<void> {
try {
const data = await api<AuditResponse>("/audit-logs");
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
<tr>
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
<td>${escapeHTML(item.actor)}</td>
<td>${escapeHTML(formatAuditAction(item.action))}</td>
<td>${escapeHTML(formatTargetType(item.target_type))}:${escapeHTML(item.target_id)}</td>
<td>${badge(item.success ? t("success") : t("failed"), !item.success)}</td>
<td>${escapeHTML(localizeMessage(item.message || ""))}</td>
</tr>
`).join("");
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,27 @@
// cmd/gateway/admin_frontend/src/views/metrics.ts 渲染管理端成员可见的网关指标计数器。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeHTML, stat } from "../dom.js";
import { t } from "../i18n.js";
import type { Metrics } from "../types.js";
export async function loadMetrics(): Promise<void> {
try {
const data = await api<Metrics>("/metrics");
el("metricsGrid").innerHTML = [
stat(t("total"), data.total_connections),
stat(t("activeConnections"), data.active_connections),
stat("TCP", data.tcp_connections),
stat("WebSocket", data.websocket_connections),
stat(t("misses"), data.route_misses),
stat(t("dialErrors"), data.upstream_dial_errors),
].join("");
const hits = data.route_hits || {};
el("routeHits").innerHTML = Object.keys(hits).length
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
: `<span class="chip">${escapeHTML(t("noHits"))}</span>`;
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,994 @@
// cmd/gateway/admin_frontend/src/views/plugins.ts 渲染插件清单、插件详情、配置/密钥/治理动作和运维工具。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { isAdmin } from "../session.js";
import { state } from "../state.js";
import type { PluginArtifact, PluginBuild, PluginDryRunResult, PluginInstrumentation, PluginOperations, PluginProxyConnection, PluginSecret, PluginServiceStatus, PluginSnapshot, PluginView } from "../types.js";
interface PluginsResponse {
plugins?: PluginView[];
}
interface ArtifactsResponse {
artifacts?: PluginArtifact[];
}
interface BuildsResponse {
builds?: PluginBuild[];
}
interface PluginServiceResponse {
plugin_service?: PluginServiceStatus;
}
interface InstrumentationResponse {
instrumentation?: PluginInstrumentation[];
}
interface PluginResponse {
plugin?: PluginView;
}
interface DryRunResponse {
result?: PluginDryRunResult;
}
interface SnapshotDiffResponse {
diff?: {
redacted_diff_json?: string;
sensitive_paths?: string[];
restart_required?: boolean;
};
}
interface OperationsResponse {
operations?: PluginOperations;
candidates?: Record<string, unknown>[];
diagnostic?: Record<string, unknown>;
summary?: Record<string, unknown>;
}
export async function loadPlugins(): Promise<void> {
try {
// 插件页首屏依赖插件记录、制品、构建、插件服务模式和观测数据;
// 并行请求可以减少进入页面时的等待时间。
const [data, artifacts, builds, service, instrumentation] = await Promise.all([
api<PluginsResponse>("/plugins"),
api<ArtifactsResponse>("/plugin-artifacts"),
api<BuildsResponse>("/plugin-builds"),
api<PluginServiceResponse>("/plugin-service"),
api<InstrumentationResponse>("/plugin-instrumentation"),
]);
state.plugins = data.plugins || [];
state.pluginArtifacts = artifacts.artifacts || [];
state.pluginBuilds = builds.builds || [];
state.pluginService = service.plugin_service || null;
state.pluginInstrumentation = instrumentation.instrumentation || [];
const firstPlugin = state.plugins[0];
if (!state.selectedPluginID && firstPlugin) {
// 初次进入时默认选中第一个已纳管插件;未纳管制品会在列表中单独展示。
state.selectedPluginID = firstPlugin.id;
}
renderPlugins();
if (state.selectedPluginID) {
await loadPluginDetail(state.selectedPluginID);
} else {
renderPluginDetail(null);
}
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderPlugins(): void {
const managed = new Set(state.plugins.map((plugin) => plugin.id));
// 未纳管制品还没有 plugins 表记录,但仍要展示,方便管理员创建期望状态。
const unmanagedArtifacts = state.pluginArtifacts.filter((artifact) => !managed.has(artifact.plugin_id));
renderPluginServicePanel();
el("pluginsBody").innerHTML = state.plugins.map((plugin) => `
<tr class="${plugin.id === state.selectedPluginID ? "selected" : ""}">
<td><button class="link-button" type="button" data-plugin-detail="${escapeAttr(plugin.id)}">${escapeHTML(plugin.id)}</button></td>
<td>${escapeHTML(plugin.name || "")}</td>
<td>${escapeHTML(plugin.version || "")}</td>
<td>${escapeHTML(plugin.artifact_type || "")}</td>
<td>${escapeHTML(plugin.runtime_type || "")}</td>
<td>${escapeHTML(plugin.desired_state)}</td>
<td>${badge(plugin.runtime_state, plugin.runtime_state !== "enabled")}</td>
<td>${escapeHTML(shortID(plugin.desired_artifact_id))}</td>
<td>${escapeHTML((plugin.extension_points || []).join(", "))}</td>
<td>${escapeHTML(String(plugin.priority))}</td>
<td>${badge(plugin.restart_required ? "yes" : "no", !plugin.restart_required)}</td>
<td>${badge(plugin.health || "", plugin.health !== "healthy")}</td>
</tr>
`).concat(unmanagedArtifacts.map((artifact) => `
<tr class="${artifact.id === state.selectedArtifactID ? "selected" : ""}">
<td><button class="link-button" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">${escapeHTML(artifact.plugin_id)}</button></td>
<td>${escapeHTML(artifact.file_name || "")}</td>
<td>${escapeHTML(artifact.version || "")}</td>
<td>${escapeHTML(artifact.artifact_type)}</td>
<td>${escapeHTML(artifact.runtime_type || "")}</td>
<td>${escapeHTML(artifact.status)}</td>
<td>${badge("not managed", true)}</td>
<td>${escapeHTML(shortID(artifact.id))}</td>
<td>${escapeHTML(jsonList(artifact.extension_points_json))}</td>
<td></td>
<td>${badge("no", false)}</td>
<td>${badge(artifact.error ? "error" : "pending", Boolean(artifact.error))}</td>
</tr>
`)).join("");
document.querySelectorAll<HTMLButtonElement>("[data-plugin-detail]").forEach((button) => {
button.addEventListener("click", () => {
const id = button.dataset.pluginDetail;
if (id) {
state.selectedPluginID = id;
state.selectedArtifactID = "";
loadPluginDetail(id);
}
});
});
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedArtifactID = button.dataset.artifactDetail || "";
state.selectedPluginID = "";
renderPlugins();
renderPluginDetail(null);
});
});
}
export async function loadPluginDetail(pluginID: string): Promise<void> {
try {
const data = await api<PluginResponse>(`/plugins/${encodeURIComponent(pluginID)}`);
if (data.plugin) {
// 详情接口返回完整插件视图,用它回填列表中的摘要记录。
state.plugins = state.plugins.map((plugin) => plugin.id === data.plugin?.id ? data.plugin : plugin);
if (!state.plugins.some((plugin) => plugin.id === data.plugin?.id)) {
state.plugins.push(data.plugin);
}
state.selectedPluginID = data.plugin.id;
renderPlugins();
renderPluginDetail(data.plugin);
}
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderPluginDetail(plugin: PluginView | null = selectedPlugin()): void {
const detail = el("pluginDetail");
if (!plugin) {
// 没有选中纳管插件时展示制品库存和源码构建入口。
detail.innerHTML = uploadInventoryDetail();
bindInventoryEvents();
return;
}
const canWrite = isAdmin();
// 插件详情拆成多个小面板,避免配置、治理、构建和运维信息混成一个长表格。
detail.innerHTML = `
<div class="detail-header">
<div>
<h2>${escapeHTML(plugin.name || plugin.id)}</h2>
<p>${escapeHTML(plugin.id)} · ${escapeHTML(plugin.version || "")}</p>
</div>
<div class="row-actions">${canWrite ? pluginActionButtons(plugin) : ""}</div>
</div>
<div class="status-grid dense">
${detailStat("Runtime", plugin.runtime_state)}
${detailStat("Desired", plugin.desired_state)}
${detailStat("Active", shortID(plugin.active_artifact_id))}
${detailStat("Desired artifact", shortID(plugin.desired_artifact_id))}
${detailStat("Loaded", shortID(plugin.loaded_artifact_id))}
${detailStat("Restart", plugin.restart_required ? "required" : "not required")}
${detailStat("Health", plugin.health || "")}
${detailStat("Active proxy", plugin.active_proxy_connections || 0)}
</div>
${plugin.last_error ? `<div class="alert inline-alert">${escapeHTML(plugin.last_error)}</div>` : ""}
<div class="plugin-layout">
<section class="panel">
<h3>Manifest</h3>
<dl class="kv">
<dt>Runtime</dt><dd>${escapeHTML(plugin.runtime_type || "")}</dd>
<dt>Extensions</dt><dd>${escapeHTML((plugin.extension_points || []).join(", "))}</dd>
<dt>Scope</dt><dd>${escapeHTML(formatJSON(plugin.scope))}</dd>
<dt>Rollout</dt><dd>${escapeHTML(formatJSON(plugin.rollout))}</dd>
<dt>Minecraft</dt><dd>${escapeHTML(formatJSON(plugin.minecraft))}</dd>
</dl>
</section>
<section class="panel">
<h3>Governance</h3>
${governancePanel(plugin, canWrite)}
</section>
<section class="panel">
<h3>Config</h3>
<textarea id="pluginConfigEditor" ${canWrite ? "" : "readonly"}>${escapeHTML(prettyJSON(plugin.config_json || "{}"))}</textarea>
<div class="row-actions">${canWrite ? `
<button type="button" id="pluginDryRunBtn">Dry run</button>
<button type="button" id="pluginSaveConfigBtn">Save config</button>
` : ""}</div>
<pre id="pluginDryRunResult" class="log-output"></pre>
</section>
<section class="panel">
<h3>Secrets</h3>
<div class="chips">${(plugin.secrets || []).map(secretChip).join("") || `<span class="chip">No secrets</span>`}</div>
${canWrite ? `
<form id="pluginSecretForm" class="inline-form">
<input name="name" placeholder="secret name" required>
<input name="value" type="password" placeholder="value" required>
<label class="inline"><input name="reload_required" type="checkbox"> Reload required</label>
<label class="inline"><input name="hot_reload" type="checkbox" checked> Hot reload</label>
<button type="submit">Update secret</button>
</form>
` : ""}
</section>
<section class="panel">
<h3>Artifacts</h3>
${artifactList(plugin.artifacts || [], plugin)}
</section>
<section class="panel">
<h3>Builds</h3>
${buildList(plugin.builds || [], plugin, canWrite)}
</section>
<section class="panel">
<h3>Snapshots</h3>
${snapshotList(plugin.snapshots || [], canWrite)}
</section>
<section class="panel">
<h3>Dispatch plan</h3>
<pre class="log-output">${escapeHTML(formatJSON(plugin.dispatch_summary || []))}</pre>
</section>
<section class="panel">
<h3>Extension status</h3>
<pre class="log-output">${escapeHTML(formatJSON(plugin.extension_status || {}))}</pre>
</section>
<section class="panel">
<h3>Operations</h3>
<div class="row-actions">
<button class="secondary" type="button" id="pluginOperationsLoadBtn">Refresh</button>
<button class="secondary" type="button" id="pluginOperationsGCDryRunBtn">GC dry-run</button>
<button class="secondary" type="button" id="pluginDiagnosticBtn">Diagnostic</button>
</div>
<pre id="pluginOperationsOutput" class="log-output"></pre>
</section>
<section class="panel">
<h3>Active proxy connections</h3>
${proxyConnectionList(plugin.proxy_connections || [])}
</section>
</div>
`;
bindPluginDetailEvents(plugin);
}
export function bindPluginEvents(): void {
// 顶层插件页事件只绑定一次;详情区会在每次重绘后重新绑定动态按钮。
el<HTMLInputElement>("pluginUploadInput").addEventListener("change", uploadPluginPackage);
el<HTMLButtonElement>("refreshPluginsBtn").addEventListener("click", loadPlugins);
}
function renderPluginServicePanel(): void {
const container = document.getElementById("pluginServicePanel");
if (!container) {
return;
}
const service = state.pluginService?.service;
const canWrite = isAdmin();
// 插件服务模式决定插件在进程内运行还是进入未来的独立/沙箱运行模式。
container.innerHTML = `
<section class="panel">
<div class="detail-header compact">
<div>
<h3>Plugin Service</h3>
<p>${service ? `active ${escapeHTML(service.active_mode)} · desired ${escapeHTML(service.desired_mode)}` : "not loaded"}</p>
</div>
${service ? badge(service.restart_required ? "restart required" : "applied", service.restart_required) : ""}
</div>
${service ? `
<div class="status-grid dense">
${detailStat("Desired mode", service.desired_mode)}
${detailStat("Active mode", service.active_mode)}
${detailStat("Migration", service.live_migration || "drain-only")}
${detailStat("Restart", service.restart_required ? "required" : "not required")}
</div>
${service.last_error ? `<div class="alert inline-alert">${escapeHTML(service.last_error)}</div>` : ""}
${canWrite ? `
<form id="pluginServiceForm" class="inline-form">
<select name="desired_mode">
${["in-process", "go-plugin-process", "sandbox-process"].map((mode) => `<option value="${mode}" ${mode === service.desired_mode ? "selected" : ""}>${mode}</option>`).join("")}
</select>
<button type="submit">Set desired</button>
</form>
` : ""}
` : ""}
</section>
<section class="panel">
<h3>Build-Time Instrumentation</h3>
${instrumentationList(state.pluginInstrumentation)}
</section>
`;
const form = document.getElementById("pluginServiceForm");
if (form instanceof HTMLFormElement) {
form.addEventListener("submit", updatePluginServiceMode);
}
}
async function updatePluginServiceMode(event: Event): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
try {
// 服务模式变更可能需要后端迁移或重启,因此保存后立即刷新插件页状态。
await api("/plugin-service", {
method: "PUT",
body: { desired_mode: getFormInput(form, "desired_mode") },
});
await loadPlugins();
} catch (err) {
showAlert((err as Error).message);
}
}
function instrumentationList(records: PluginInstrumentation[]): string {
if (!records.length) {
return `<p class="muted">No instrumentation metadata</p>`;
}
return `<table class="mini-table">
<thead><tr><th>Name</th><th>Profile</th><th>Status</th><th>Diff</th><th>Rollback</th></tr></thead>
<tbody>${records.map((record) => `
<tr>
<td>${escapeHTML(record.name)} ${escapeHTML(record.version || "")}</td>
<td>${escapeHTML(record.profile || "")}</td>
<td>${badge(record.status || "available", record.status === "blocked")}</td>
<td>${escapeHTML(shortID(record.generated_diff_hash || ""))}</td>
<td>${escapeHTML(record.runbook_rollback || "")}</td>
</tr>
`).join("")}</tbody>
</table>`;
}
async function uploadPluginPackage(event: Event): Promise<void> {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
const formData = new FormData();
formData.set("artifact", file);
try {
// 浏览器只负责上传文件manifest 校验、哈希和制品类型判断由后端完成。
await api("/plugin-artifacts", { method: "POST", formData });
input.value = "";
await loadPlugins();
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
function bindPluginDetailEvents(plugin: PluginView): void {
// 详情区每次重绘都会替换 DOM因此按钮事件必须在重绘后重新绑定。
document.getElementById("pluginDryRunBtn")?.addEventListener("click", () => dryRunConfig(plugin));
document.getElementById("pluginSaveConfigBtn")?.addEventListener("click", () => saveConfig(plugin));
const secretForm = document.getElementById("pluginSecretForm");
if (secretForm instanceof HTMLFormElement) {
secretForm.addEventListener("submit", (event) => saveSecret(event, plugin));
}
document.querySelectorAll<HTMLButtonElement>("[data-plugin-action]").forEach((button) => {
button.addEventListener("click", () => runPluginAction(plugin.id, button.dataset.pluginAction || ""));
});
document.querySelectorAll<HTMLButtonElement>("[data-artifact-rollback]").forEach((button) => {
button.addEventListener("click", () => rollbackArtifact(plugin.id, button.dataset.artifactRollback || ""));
});
document.querySelectorAll<HTMLButtonElement>("[data-build-action]").forEach((button) => {
button.addEventListener("click", () => runBuildAction(plugin.id, Number(button.dataset.buildId || "0"), button.dataset.buildAction || ""));
});
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-rollback]").forEach((button) => {
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotRollback || "0"), false));
});
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-full-rollback]").forEach((button) => {
button.addEventListener("click", () => rollbackSnapshot(plugin.id, Number(button.dataset.snapshotFullRollback || "0"), true));
});
document.querySelectorAll<HTMLButtonElement>("[data-snapshot-diff]").forEach((button) => {
button.addEventListener("click", () => showSnapshotDiff(plugin.id, Number(button.dataset.snapshotDiff || "0")));
});
document.getElementById("pluginGovernanceReviewBtn")?.addEventListener("click", () => createGovernanceReview(plugin));
document.getElementById("pluginGovernanceOverrideBtn")?.addEventListener("click", () => createGovernanceOverride(plugin));
document.getElementById("pluginGovernancePreflightBtn")?.addEventListener("click", () => runGovernancePreflight(plugin));
document.getElementById("pluginGovernanceSelfTestBtn")?.addEventListener("click", () => runGovernanceSelfTest(plugin));
document.getElementById("pluginGovernanceBenchmarkBtn")?.addEventListener("click", () => recordGovernanceBenchmark(plugin));
document.getElementById("pluginGovernanceAdvisoryBtn")?.addEventListener("click", () => createArtifactRevokeAdvisory(plugin));
document.getElementById("pluginOperationsLoadBtn")?.addEventListener("click", () => loadPluginOperations(plugin));
document.getElementById("pluginOperationsGCDryRunBtn")?.addEventListener("click", () => dryRunOperationsGC(plugin));
document.getElementById("pluginDiagnosticBtn")?.addEventListener("click", () => loadDiagnosticPackage(plugin));
}
async function dryRunConfig(plugin: PluginView): Promise<void> {
try {
// dry-run 不保存配置只返回脱敏后的校验结果、diff 和是否需要重启。
const data = await api<DryRunResponse>(`/plugins/${encodeURIComponent(plugin.id)}/config/dry-run`, {
method: "POST",
body: {
artifact_id: plugin.desired_artifact_id,
config_json: configEditorValue(),
},
});
el("pluginDryRunResult").textContent = formatJSON(data.result || {});
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function saveConfig(plugin: PluginView): Promise<void> {
try {
// 配置保存写入期望状态;后端会根据当前制品和运行态判断是否可热加载。
await api(`/plugins/${encodeURIComponent(plugin.id)}/config`, {
method: "PUT",
body: {
artifact_id: plugin.desired_artifact_id,
desired_state: plugin.desired_state,
priority: plugin.priority,
config_json: configEditorValue(),
},
});
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function saveSecret(event: SubmitEvent, plugin: PluginView): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
try {
// 密钥值不回显,保存后通过重新加载详情刷新版本号和 reload 标记。
await api(`/plugins/${encodeURIComponent(plugin.id)}/secrets`, {
method: "POST",
body: {
name: getFormInput(form, "name").value,
artifact_id: plugin.desired_artifact_id,
value: getFormInput(form, "value").value,
reload_required: getFormInput(form, "reload_required").checked,
hot_reload: getFormInput(form, "hot_reload").checked,
},
});
form.reset();
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function runPluginAction(pluginID: string, action: string): Promise<void> {
if (!action) {
return;
}
try {
// enable/disable/load/delete 等动作都走统一动作接口,后端负责审计和操作日志。
await api(`/plugins/${encodeURIComponent(pluginID)}/${action}`, { method: "POST", body: {} });
if (action === "delete") {
state.selectedPluginID = "";
await loadPlugins();
} else {
await loadPluginDetail(pluginID);
}
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function createDesiredFromArtifact(artifact: PluginArtifact): Promise<void> {
try {
// 从未纳管制品创建 disabled 期望状态,管理员随后可以编辑配置再启用。
await api(`/plugins/${encodeURIComponent(artifact.plugin_id)}`, {
method: "PUT",
body: {
artifact_id: artifact.id,
desired_state: "disabled",
priority: Number(getFormInput(el<HTMLFormElement>("artifactDesiredForm"), "priority").value || "100"),
config_json: el<HTMLTextAreaElement>("artifactConfigEditor").value || "{}",
},
});
state.selectedArtifactID = "";
state.selectedPluginID = artifact.plugin_id;
await loadPlugins();
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function rollbackArtifact(pluginID: string, artifactID: string): Promise<void> {
try {
// 制品回滚只改期望制品;后端仍会执行治理检查和配置 dry-run。
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/artifact`, {
method: "POST",
body: { artifact_id: artifactID },
});
await loadPluginDetail(pluginID);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function runBuildAction(pluginID: string, buildID: number, action: string): Promise<void> {
if (!buildID || !action) {
return;
}
try {
// 构建动作可能耗时,当前界面以刷新详情的方式展示最新构建状态。
await api(`/plugin-builds/${buildID}/${encodeURIComponent(action)}`, { method: "POST", body: {} });
await loadPluginDetail(pluginID);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function rollbackSnapshot(pluginID: string, snapshotID: number, fullDesired: boolean): Promise<void> {
if (!snapshotID) {
return;
}
try {
// 配置快照回滚可只恢复配置,也可连同 artifact/desired state/priority 一起恢复。
await api(`/plugins/${encodeURIComponent(pluginID)}/rollback/config`, {
method: "POST",
body: { snapshot_id: snapshotID, full_desired: fullDesired },
});
await loadPluginDetail(pluginID);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function showSnapshotDiff(pluginID: string, snapshotID: number): Promise<void> {
if (!snapshotID) {
return;
}
try {
// diff 已由后端脱敏,前端只负责展示结果给管理员确认。
const data = await api<SnapshotDiffResponse>(`/plugins/${encodeURIComponent(pluginID)}/config/snapshots/${snapshotID}/diff`);
el("pluginDryRunResult").textContent = formatJSON(data.diff || {});
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function createGovernanceReview(plugin: PluginView): Promise<void> {
try {
// 评审记录绑定当前 desired artifact 和配置哈希,用于后续启用或回滚门禁。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/review`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", decision: "approved" },
});
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function createGovernanceOverride(plugin: PluginView): Promise<void> {
const reason = window.prompt("Reason");
if (!reason) {
return;
}
try {
// override 是带 TTL 的临时治理豁免,必须记录人工原因。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/override`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, profile: "prod", action: "enable", reason, ttl_seconds: 3600 },
});
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function runGovernancePreflight(plugin: PluginView): Promise<void> {
try {
// preflight 由插件或宿主返回检查项,结果会持久化到治理面板。
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/preflight`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id, config_json: configEditorValue() },
});
el("pluginDryRunResult").textContent = formatJSON(data);
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function runGovernanceSelfTest(plugin: PluginView): Promise<void> {
try {
// self-test 用于验证制品自身能力,不直接修改 desired state。
const data = await api<Record<string, unknown>>(`/plugins/${encodeURIComponent(plugin.id)}/governance/self-test`, {
method: "POST",
body: { artifact_id: plugin.desired_artifact_id },
});
el("pluginDryRunResult").textContent = formatJSON(data);
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function recordGovernanceBenchmark(plugin: PluginView): Promise<void> {
const diff = Number(window.prompt("Baseline diff, e.g. 0.25", "0.25"));
if (!Number.isFinite(diff)) {
return;
}
try {
// 手动录入基准差异用于治理门禁判断,避免高风险性能回退直接启用。
await api(`/plugins/${encodeURIComponent(plugin.id)}/governance/benchmark`, {
method: "POST",
body: {
artifact_id: plugin.desired_artifact_id,
profile: "prod",
benchmark_profile: "manual",
p95_ms: 0,
p99_ms: 0,
error_rate: 0,
active_proxy_capacity: 0,
baseline_diff: diff,
},
});
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function createArtifactRevokeAdvisory(plugin: PluginView): Promise<void> {
const artifact = plugin.desired_artifact;
if (!artifact) {
return;
}
const advisoryID = window.prompt("Advisory ID", `local-${shortID(artifact.sha256)}`);
if (!advisoryID) {
return;
}
try {
// 撤销公告会让命中的制品进入隔离/阻断路径,详情刷新后展示最新治理状态。
await api("/plugin-advisories", {
method: "POST",
body: {
advisory_id: advisoryID,
status: "revoked",
action: "revoke",
artifact_sha256: artifact.sha256,
recommended_action: "rollback or upgrade",
},
});
await loadPluginDetail(plugin.id);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function loadPluginOperations(plugin: PluginView): Promise<void> {
try {
// 运维快照包含事件、日志、trace、任务、外部依赖和 GC 候选项,按需刷新即可。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations`);
el("pluginOperationsOutput").textContent = formatJSON(data.operations || {});
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function dryRunOperationsGC(plugin: PluginView): Promise<void> {
try {
// GC dry-run 不删除文件,只展示哪些运行态数据会被保护或清理。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/gc`);
el("pluginOperationsOutput").textContent = formatJSON(data);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function loadDiagnosticPackage(plugin: PluginView): Promise<void> {
try {
// 诊断包由后端生成并脱敏,前端以 JSON 文本形式展示给管理员。
const data = await api<OperationsResponse>(`/plugins/${encodeURIComponent(plugin.id)}/operations/diagnostic`);
el("pluginOperationsOutput").textContent = formatJSON(data);
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
function uploadInventoryDetail(): string {
// 库存视图聚合未纳管制品和构建记录,支撑上传、构建、纳管的完整流程。
const artifact = selectedArtifact();
if (!artifact) {
return `
<div class="plugin-layout">
<section class="panel">
<h3>Uploaded artifacts</h3>
${artifactInventoryList(state.pluginArtifacts)}
</section>
<section class="panel">
<h3>Builds</h3>
${buildInventoryList(state.pluginBuilds)}
</section>
</div>
`;
}
const canWrite = isAdmin() && artifact.artifact_type === "binary";
return `
<div class="detail-header">
<div>
<h2>${escapeHTML(artifact.plugin_id)}</h2>
<p>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(shortID(artifact.id))}</p>
</div>
</div>
<div class="plugin-layout">
<section class="panel">
<h3>Artifact</h3>
<dl class="kv">
<dt>Status</dt><dd>${escapeHTML(artifact.status)}</dd>
<dt>Runtime</dt><dd>${escapeHTML(artifact.runtime_type || "")}</dd>
<dt>Go/API</dt><dd>${escapeHTML(`${artifact.go_version || ""} ${artifact.api_version || ""}`)}</dd>
<dt>SHA256</dt><dd>${escapeHTML(artifact.sha256)}</dd>
<dt>Extensions</dt><dd>${escapeHTML(jsonList(artifact.extension_points_json))}</dd>
<dt>Error</dt><dd>${escapeHTML(artifact.error || "")}</dd>
</dl>
</section>
<section class="panel">
<h3>Create desired state</h3>
${canWrite ? `
<form id="artifactDesiredForm" class="inline-form">
<label>Priority<input name="priority" type="number" value="100"></label>
<textarea id="artifactConfigEditor">{}</textarea>
<button type="submit">Create disabled plugin</button>
</form>
` : `<div class="empty">Only binary artifacts can be used as plugin desired state.</div>`}
</section>
<section class="panel">
<h3>Builds</h3>
${buildInventoryList(state.pluginBuilds.filter((build) => build.plugin_id === artifact.plugin_id))}
</section>
</div>
`;
}
function bindInventoryEvents(): void {
// 库存视图也是动态渲染,制品详情和纳管表单事件需要在渲染后绑定。
const artifact = selectedArtifact();
const form = document.getElementById("artifactDesiredForm");
if (artifact && form instanceof HTMLFormElement) {
form.addEventListener("submit", (event) => {
event.preventDefault();
createDesiredFromArtifact(artifact);
});
}
document.querySelectorAll<HTMLButtonElement>("[data-artifact-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedArtifactID = button.dataset.artifactDetail || "";
renderPlugins();
renderPluginDetail(null);
});
});
}
function artifactInventoryList(artifacts: PluginArtifact[]): string {
if (artifacts.length === 0) {
return `<div class="empty">No artifacts</div>`;
}
return `<div class="mini-list">${artifacts.map((artifact) => `
<div class="mini-row">
<span>${escapeHTML(artifact.plugin_id)}</span>
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
<span>${escapeHTML(shortID(artifact.id))}</span>
<button class="secondary" type="button" data-artifact-detail="${escapeAttr(artifact.id)}">Open</button>
</div>
`).join("")}</div>`;
}
function buildInventoryList(builds: PluginBuild[]): string {
if (builds.length === 0) {
return `<div class="empty">No builds</div>`;
}
return `<div class="mini-list">${builds.map((build) => `
<div class="mini-row">
<span>${escapeHTML(build.plugin_id)} #${escapeHTML(build.id)}</span>
<span>${badge(build.status, build.status !== "succeeded")}</span>
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
</div>
`).join("")}</div>`;
}
function selectedPlugin(): PluginView | null {
return state.plugins.find((plugin) => plugin.id === state.selectedPluginID) || null;
}
function selectedArtifact(): PluginArtifact | null {
return state.pluginArtifacts.find((artifact) => artifact.id === state.selectedArtifactID) || null;
}
function pluginActionButtons(plugin: PluginView): string {
return `
<button type="button" data-plugin-action="load">Load</button>
<button type="button" data-plugin-action="enable">Enable</button>
<button class="secondary" type="button" data-plugin-action="disable">Disable</button>
<button class="danger" type="button" data-plugin-action="delete">Delete</button>
`;
}
function governancePanel(plugin: PluginView, canWrite: boolean): string {
if (plugin.governance_error) {
return `<div class="alert inline-alert">${escapeHTML(plugin.governance_error)}</div>`;
}
const governance = plugin.governance;
const decision = governance?.decision;
const issues = decision?.issues || [];
return `
<dl class="kv">
<dt>Profile</dt><dd>${escapeHTML(decision?.profile || "")}</dd>
<dt>Risk</dt><dd>${escapeHTML(decision?.risk_level || "")}</dd>
<dt>Policy</dt><dd>${escapeHTML(shortID(decision?.policy_hash || ""))}</dd>
<dt>Decision</dt><dd>${badge(decision?.ok ? "allowed" : "blocked", !decision?.ok)}</dd>
<dt>Review</dt><dd>${badge(decision?.review_required ? "required" : "not required", Boolean(decision?.review_required))}</dd>
<dt>Override</dt><dd>${badge(decision?.warning_override_used ? "used" : "not used", Boolean(decision?.warning_override_used))}</dd>
</dl>
${issues.length ? `<div class="mini-list">${issues.map((issue) => `
<div class="mini-row">
<span>${badge(issue.severity, issue.severity !== "info")}</span>
<span>${escapeHTML(issue.code)}</span>
<span>${escapeHTML(issue.message)}</span>
</div>
`).join("")}</div>` : `<div class="empty">No governance issues</div>`}
${canWrite ? `
<div class="row-actions">
<button class="secondary" type="button" id="pluginGovernanceReviewBtn">Review</button>
<button class="secondary" type="button" id="pluginGovernanceOverrideBtn">Override</button>
<button class="secondary" type="button" id="pluginGovernancePreflightBtn">Preflight</button>
<button class="secondary" type="button" id="pluginGovernanceSelfTestBtn">Self-test</button>
<button class="secondary" type="button" id="pluginGovernanceBenchmarkBtn">Benchmark</button>
<button class="danger" type="button" id="pluginGovernanceAdvisoryBtn">Revoke artifact</button>
</div>
` : ""}
<pre class="log-output">${escapeHTML(formatJSON({
conflicts: governance?.conflicts,
reviews: governance?.reviews || [],
warning_overrides: governance?.warning_overrides || [],
preflights: governance?.preflights || [],
benchmarks: governance?.benchmarks || [],
advisories: governance?.advisories || [],
}))}</pre>
`;
}
function artifactList(artifacts: PluginArtifact[], plugin: PluginView): string {
if (artifacts.length === 0) {
return `<div class="empty">No artifacts</div>`;
}
return `<div class="mini-list">${artifacts.map((artifact) => `
<div class="mini-row">
<span>${escapeHTML(shortID(artifact.id))}</span>
<span>${escapeHTML(artifact.version)} · ${escapeHTML(artifact.artifact_type)} · ${escapeHTML(artifact.status)}</span>
<span>${escapeHTML(artifact.go_version || "")}</span>
${isAdmin() && artifact.artifact_type === "binary" && artifact.id !== plugin.desired_artifact_id ? `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(artifact.id)}">Rollback</button>` : ""}
</div>
`).join("")}</div>`;
}
function buildList(builds: PluginBuild[], plugin: PluginView, canWrite: boolean): string {
if (builds.length === 0) {
return `<div class="empty">No builds</div>`;
}
return `<div class="mini-list">${builds.map((build) => `
<div class="mini-row">
<span>#${escapeHTML(build.id)}</span>
<span>${badge(build.status, build.status !== "succeeded")}</span>
<span>${escapeHTML(shortID(build.artifact_id || build.source_id))}</span>
<span>${escapeHTML(build.log_summary || build.error || "")}</span>
${canWrite ? buildActions(build, plugin) : ""}
</div>
`).join("")}</div>`;
}
function buildActions(build: PluginBuild, plugin: PluginView): string {
if (build.status === "queued") {
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="run">Run</button>`;
}
if (build.status === "running") {
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="cancel">Cancel</button>`;
}
if (build.status === "failed" || build.status === "canceled") {
return `<button class="secondary" type="button" data-build-id="${escapeAttr(build.id)}" data-build-action="retry">Retry</button>`;
}
if (build.status === "succeeded" && build.artifact_id && build.artifact_id !== plugin.desired_artifact_id) {
return `<button class="secondary" type="button" data-artifact-rollback="${escapeAttr(build.artifact_id)}">Use artifact</button>`;
}
return "";
}
function snapshotList(snapshots: PluginSnapshot[], canWrite: boolean): string {
if (snapshots.length === 0) {
return `<div class="empty">No snapshots</div>`;
}
return `<div class="mini-list">${snapshots.map((snapshot) => `
<div class="mini-row">
<span>#${escapeHTML(snapshot.id)}</span>
<span>gen ${escapeHTML(snapshot.desired_generation)} · ${escapeHTML(snapshot.desired_state)} · ${escapeHTML(shortID(snapshot.artifact_id))}</span>
<button class="secondary" type="button" data-snapshot-diff="${escapeAttr(snapshot.id)}">Diff</button>
${canWrite ? `
<button class="secondary" type="button" data-snapshot-rollback="${escapeAttr(snapshot.id)}">Config rollback</button>
<button class="secondary" type="button" data-snapshot-full-rollback="${escapeAttr(snapshot.id)}">Full rollback</button>
` : ""}
</div>
`).join("")}</div>`;
}
function proxyConnectionList(connections: PluginProxyConnection[]): string {
if (connections.length === 0) {
return `<div class="empty">No active proxy connections</div>`;
}
return `<div class="mini-list">${connections.map((conn) => `
<div class="mini-row">
<span>#${escapeHTML(conn.id)}</span>
<span>${escapeHTML(shortID(conn.artifact_id))} · ${escapeHTML(conn.handler_id)}</span>
<span>${escapeHTML(conn.duration_ms)}ms</span>
<span>${badge(conn.draining ? "draining" : "active", conn.draining)}</span>
</div>
`).join("")}</div>`;
}
function secretChip(secret: PluginSecret): string {
const reload = secret.reload_required ? "reload required" : "hot reload";
return `<span class="chip">${escapeHTML(secret.name)} v${escapeHTML(secret.current_version)} · prev ${escapeHTML(secret.previous_version)} · ${escapeHTML(reload)}</span>`;
}
function detailStat(label: string, value: unknown): string {
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
}
function configEditorValue(): string {
return el<HTMLTextAreaElement>("pluginConfigEditor").value;
}
function shortID(id?: string): string {
return id ? id.slice(0, 12) : "";
}
function prettyJSON(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
function jsonList(raw?: string): string {
if (!raw) {
return "";
}
try {
const value = JSON.parse(raw);
return Array.isArray(value) ? value.join(", ") : String(value);
} catch {
return raw;
}
}
function formatJSON(value: unknown): string {
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return String(value ?? "");
}
}

View File

@@ -0,0 +1,110 @@
// cmd/gateway/admin_frontend/src/views/routes.ts 渲染路由列表,并通过 Admin API 保存主机到上游的变更。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { t } from "../i18n.js";
import { isMember } from "../session.js";
import { state } from "../state.js";
import type { RouteRecord } from "../types.js";
interface RoutesResponse {
routes?: RouteRecord[];
}
export async function loadRoutes(): Promise<void> {
try {
const q = encodeURIComponent(el<HTMLInputElement>("routeSearch").value || "");
const data = await api<RoutesResponse>(q ? `/routes?q=${q}` : "/routes");
state.routes = data.routes || [];
renderRoutes();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderRoutes(): void {
const canWrite = isMember();
el("routesBody").innerHTML = state.routes.map((route) => `
<tr>
<td>${escapeHTML(route.host)}</td>
<td>${escapeHTML(route.upstream)}</td>
<td>${badge(route.enabled ? t("enabled") : t("disabled"), !route.enabled)}</td>
<td>${escapeHTML(route.note || "")}</td>
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
</tr>
`).join("");
document.querySelectorAll<HTMLButtonElement>("[data-edit-route]").forEach((button) => {
button.addEventListener("click", () => {
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
openRouteDialog(route || null);
});
});
document.querySelectorAll<HTMLButtonElement>("[data-delete-route]").forEach((button) => {
button.addEventListener("click", () => {
const host = button.dataset.deleteRoute;
if (host) {
removeRoute(host);
}
});
});
}
export function openRouteDialog(route: RouteRecord | null = null): void {
const form = el<HTMLFormElement>("routeForm");
form.reset();
form.dataset.originalHost = route ? route.host : "";
getFormInput(form, "host").disabled = Boolean(route);
if (route) {
getFormInput(form, "host").value = route.host;
getFormInput(form, "upstream").value = route.upstream;
getFormInput(form, "enabled").checked = route.enabled;
getFormInput(form, "note").value = route.note || "";
} else {
getFormInput(form, "enabled").checked = true;
}
el<HTMLDialogElement>("routeDialog").showModal();
}
export async function saveRoute(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const host = form.dataset.originalHost || getFormInput(form, "host").value;
try {
await api(`/routes/${encodeURIComponent(host)}`, {
method: "PUT",
body: {
upstream: getFormInput(form, "upstream").value,
enabled: getFormInput(form, "enabled").checked,
note: getFormInput(form, "note").value,
},
});
el<HTMLDialogElement>("routeDialog").close();
await loadRoutes();
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function removeRoute(host: string): Promise<void> {
if (host === "default" && !confirm(t("deleteDefaultRouteConfirm"))) {
return;
}
try {
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
await loadRoutes();
} catch (err) {
showAlert((err as Error).message);
}
}
function routeActions(route: RouteRecord): string {
return `
<div class="row-actions">
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">${escapeHTML(t("edit"))}</button>
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">${escapeHTML(t("delete"))}</button>
</div>
`;
}

View File

@@ -0,0 +1,163 @@
// cmd/gateway/admin_frontend/src/views/services.ts 渲染监听服务设置,并持久化启停、端口和选项更新。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { t } from "../i18n.js";
import { isAdmin } from "../session.js";
import { state } from "../state.js";
import type { ServiceRecord } from "../types.js";
interface ServicesResponse {
services?: ServiceRecord[];
}
const serviceNames: Record<string, string> = {
tcp_admin: "TCP/Admin",
kcp: "KCP",
quic: "QUIC",
websocket: "WebSocket",
};
export async function loadServices(): Promise<void> {
try {
const data = await api<ServicesResponse>("/services");
state.services = data.services || [];
renderServices();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderServices(): void {
el("servicesGrid").innerHTML = state.services.map((service) => `
<article class="service">
<div>
<span>${escapeHTML(formatServiceName(service.name))}</span>
<strong>${serviceStatusText(service)}</strong>
</div>
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
</article>
`).join("");
document.querySelectorAll<HTMLFormElement>("[data-service-form]").forEach((form) => {
form.addEventListener("submit", saveService);
});
document.querySelectorAll<HTMLButtonElement>("[data-restart-service]").forEach((button) => {
button.addEventListener("click", () => {
const name = button.dataset.restartService;
if (name) {
restartService(name);
}
});
});
}
function serviceSummary(service: ServiceRecord): string {
return `<div><span>${escapeHTML(t("port"))}</span><strong>${service.port}</strong></div>`;
}
function serviceForm(service: ServiceRecord): string {
const disabled = service.name === "tcp_admin" ? "disabled" : "";
const optionFields = serviceOptionFields(service);
return `
<form data-service-form="${escapeAttr(service.name)}">
<label class="inline">
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
${escapeHTML(t("enabled"))}
</label>
<label>
${escapeHTML(t("port"))}
<input name="port" type="number" min="1" max="65535" value="${service.port}">
</label>
${optionFields}
<div class="row-actions">
<button type="submit">${escapeHTML(t("save"))}</button>
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">${escapeHTML(t("restart"))}</button>
</div>
</form>
`;
}
function serviceOptionFields(service: ServiceRecord): string {
const options = service.options || {};
if (service.name === "kcp") {
return `
<label>${escapeHTML(t("dataShards"))}<input name="data_shards" type="number" min="1" value="${numberOption(options, "data_shards", 10)}"></label>
<label>${escapeHTML(t("parityShards"))}<input name="parity_shards" type="number" min="1" value="${numberOption(options, "parity_shards", 3)}"></label>
`;
}
if (service.name === "quic") {
const protocols = arrayOption(options, "application_protocols").join(",");
return `<label>${escapeHTML(t("protocols"))}<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
}
if (service.name === "websocket") {
return `<label>${escapeHTML(t("path"))}<input name="path" value="${escapeAttr(stringOption(options, "path", "/"))}"></label>`;
}
return "";
}
async function saveService(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const name = form.dataset.serviceForm;
if (!name) {
return;
}
const options: Record<string, unknown> = {};
if (name === "kcp") {
options.data_shards = Number(getFormInput(form, "data_shards").value);
options.parity_shards = Number(getFormInput(form, "parity_shards").value);
} else if (name === "quic") {
options.application_protocols = getFormInput(form, "application_protocols").value.split(",").map((item) => item.trim()).filter(Boolean);
} else if (name === "websocket") {
options.path = getFormInput(form, "path").value;
}
try {
await api(`/services/${encodeURIComponent(name)}`, {
method: "PUT",
body: {
enabled: name === "tcp_admin" ? true : getFormInput(form, "enabled").checked,
port: Number(getFormInput(form, "port").value),
options,
},
});
await loadServices();
} catch (err) {
showAlert((err as Error).message);
}
}
async function restartService(name: string): Promise<void> {
try {
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
await loadServices();
} catch (err) {
showAlert((err as Error).message);
}
}
function formatServiceName(name: string): string {
return serviceNames[name] || name;
}
function serviceStatusText(service: ServiceRecord): string {
const status = service.enabled ? t("enabled") : t("disabled");
return escapeHTML(service.restart_required ? `${status} / ${t("restartRequired")}` : status);
}
function numberOption(options: Record<string, unknown>, key: string, fallback: number): number {
const value = options[key];
return typeof value === "number" ? value : fallback;
}
function stringOption(options: Record<string, unknown>, key: string, fallback: string): string {
const value = options[key];
return typeof value === "string" ? value : fallback;
}
function arrayOption(options: Record<string, unknown>, key: string): string[] {
const value = options[key];
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}

View File

@@ -0,0 +1,27 @@
// cmd/gateway/admin_frontend/src/views/status.ts 渲染管理面板上的网关健康摘要。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, stat } from "../dom.js";
import { t } from "../i18n.js";
interface StatusResponse {
pid?: number;
uptime_seconds?: number;
db_path?: string;
tcp_admin_port?: number;
}
export async function loadStatus(): Promise<void> {
try {
const status = await api<StatusResponse>("/status");
el("statusGrid").innerHTML = [
stat("PID", status.pid),
stat(t("uptime"), `${status.uptime_seconds}s`),
stat("SQLite", status.db_path),
stat("TCP/Admin", status.tcp_admin_port),
].join("");
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,108 @@
// cmd/gateway/admin_frontend/src/views/users.ts 渲染管理用户列表,并保存账号、角色、密码和禁用状态变更。
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
import { t } from "../i18n.js";
import { formatRole } from "../session.js";
import { state } from "../state.js";
import type { User } from "../types.js";
interface UsersResponse {
users?: User[];
}
export async function loadUsers(): Promise<void> {
try {
const data = await api<UsersResponse>("/users");
state.users = data.users || [];
renderUsers();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderUsers(): void {
el("usersBody").innerHTML = state.users.map((user) => `
<tr>
<td>${escapeHTML(user.username)}</td>
<td>${escapeHTML(formatRole(user.role))}</td>
<td>${badge(user.disabled ? t("disabled") : t("activeUser"), user.disabled)}</td>
<td class="actions">
<div class="row-actions">
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">${escapeHTML(t("edit"))}</button>
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">${escapeHTML(t("delete"))}</button>
</div>
</td>
</tr>
`).join("");
document.querySelectorAll<HTMLButtonElement>("[data-edit-user]").forEach((button) => {
button.addEventListener("click", () => {
const user = state.users.find((item) => item.username === button.dataset.editUser);
openUserDialog(user || null);
});
});
document.querySelectorAll<HTMLButtonElement>("[data-delete-user]").forEach((button) => {
button.addEventListener("click", () => {
const username = button.dataset.deleteUser;
if (username) {
removeUser(username);
}
});
});
}
export function openUserDialog(user: User | null = null): void {
const form = el<HTMLFormElement>("userForm");
form.reset();
form.dataset.originalUsername = user ? user.username : "";
getFormInput(form, "username").disabled = Boolean(user);
getFormInput(form, "password").required = !user;
if (user) {
getFormInput(form, "username").value = user.username;
getFormSelect(form, "role").value = user.role;
getFormInput(form, "disabled").checked = user.disabled;
} else {
getFormSelect(form, "role").value = "member";
}
el<HTMLDialogElement>("userDialog").showModal();
}
export async function saveUser(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const username = form.dataset.originalUsername || getFormInput(form, "username").value;
const body: Record<string, unknown> = {
role: getFormSelect(form, "role").value,
disabled: getFormInput(form, "disabled").checked,
};
if (getFormInput(form, "password").value) {
body.password = getFormInput(form, "password").value;
}
try {
if (form.dataset.originalUsername) {
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
} else {
body.username = username;
await api("/users", { method: "POST", body });
}
el<HTMLDialogElement>("userDialog").close();
await loadUsers();
} catch (err) {
showAlert((err as Error).message);
}
}
async function removeUser(username: string): Promise<void> {
if (!confirm(t("deleteUserConfirm", { username }))) {
return;
}
try {
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
await loadUsers();
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,19 @@
// cmd/gateway/admin_metric_handlers.go 返回管理面板状态卡片使用的轻量运行时计数器。
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
)
var gatewayMetrics = gatewaymetrics.New()
func handleAdminMetrics(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
adminhttp.WriteJSON(w, http.StatusOK, gatewayMetrics.Snapshot())
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
// cmd/gateway/admin_route_handlers.go 提供修改路由记录的 HTTP 接口,并在提交后立刻刷新内存路由快照。
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func handleAdminRoutesList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleGuest); !ok {
return
}
routes, err := listRoutes(r.Context(), r.URL.Query().Get("q"))
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"routes": routes})
}
func handleAdminRouteItem(w http.ResponseWriter, r *http.Request, rawHost string) {
session, ok := requireRole(w, r, adminRoleMember)
if !ok {
return
}
host, err := adminhttp.PathSegment(rawHost)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
switch r.Method {
case http.MethodPut:
var req adminhttp.RouteRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
err := upsertRoute(r.Context(), session.Username, host, req.Upstream, enabled, req.Note)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_upsert", "route", host, true, "route saved")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
case http.MethodDelete:
err := deleteRoute(r.Context(), session.Username, host)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "route_delete", "route", host, true, "route deleted")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}

View File

@@ -0,0 +1,70 @@
// cmd/gateway/admin_routes.go 让内存中的主机到上游映射快照与 SQLite 路由记录保持同步。
package main
import (
"context"
"sync"
"github.com/tursom/mc-gateway/internal/adminroute"
)
var (
// routeSnapshot 是连接热路径读取的不可变快照;写路径通过 Store 整体替换它。
routeSnapshot = adminroute.NewSnapshot()
// routeWriteLock 串行化路由写入和快照刷新,避免并发写导致后写库、先发布的顺序错乱。
routeWriteLock sync.Mutex
)
// refreshRouteSnapshot 从 SQLite 读取启用路由并发布到热路径。数据库尚未初始化时
// 发布空快照,方便测试和早期启动路径调用。
func refreshRouteSnapshot(ctx context.Context) error {
if adminDB == nil {
publishRouteSnapshot(map[string]string{})
return nil
}
routes, err := adminroute.NewRepository(adminDB).EnabledMap(ctx)
if err != nil {
return err
}
publishRouteSnapshot(routes)
return nil
}
// publishRouteSnapshot 原子替换当前路由快照;调用方应传入新 map避免发布后继续修改。
func publishRouteSnapshot(routes map[string]string) {
routeSnapshot.Store(routes)
}
// lookupRoute 是连接热路径使用的只读查找函数,不访问 SQLite。
func lookupRoute(host string) (string, bool) {
return routeSnapshot.Lookup(host)
}
func listRoutes(ctx context.Context, query string) ([]adminroute.Record, error) {
return adminroute.NewRepository(adminDB).List(ctx, query)
}
func upsertRoute(ctx context.Context, actor, host, upstream string, enabled bool, note string) error {
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
// 路由写入成功后必须立即刷新内存快照,否则管理端保存的配置不会影响新连接。
if err := adminroute.NewRepository(adminDB).Upsert(ctx, actor, host, upstream, enabled, note); err != nil {
return err
}
return refreshRouteSnapshot(ctx)
}
func deleteRoute(ctx context.Context, actor, host string) error {
routeWriteLock.Lock()
defer routeWriteLock.Unlock()
// 删除也走同一把锁,确保快照刷新顺序与数据库提交顺序一致。
if err := adminroute.NewRepository(adminDB).Delete(ctx, host); err != nil {
return err
}
_ = actor
return refreshRouteSnapshot(ctx)
}

View File

@@ -0,0 +1,113 @@
// cmd/gateway/admin_runtime.go 打开 SQLite 运行态数据库、写入默认数据,并为在线流量发布首个路由快照。
package main
import (
"context"
"database/sql"
"os"
"path/filepath"
"time"
"github.com/tursom/mc-gateway/internal/adminconfig"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/internal/adminservice"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
const (
defaultAdminDBPath = adminconfig.DefaultDBPath
defaultAdminPath = adminconfig.DefaultAdminPath
defaultAdminAPIPrefix = adminconfig.DefaultAdminAPIPrefix
defaultAdminSessionTTL = adminconfig.DefaultSessionTTL
defaultKCPPort = adminservice.DefaultKCPPort
defaultKCPDataShards = adminservice.DefaultKCPDataShards
defaultKCPParityShards = adminservice.DefaultKCPParityShards
defaultQUICPort = adminservice.DefaultQUICPort
defaultWebSocketPort = adminservice.DefaultWebSocketPort
defaultWebSocketPath = adminservice.DefaultWebSocketPath
serviceNameTCPAdmin = adminservice.NameTCPAdmin
serviceNameKCP = adminservice.NameKCP
serviceNameQUIC = adminservice.NameQUIC
serviceNameWebSocket = adminservice.NameWebSocket
adminEnvDB = adminconfig.EnvDB
adminEnvTCPAdminPort = adminconfig.EnvTCPAdminPort
adminEnvPath = adminconfig.EnvPath
adminEnvAPIPrefix = adminconfig.EnvAPIPrefix
adminEnvInitialPassword = adminconfig.EnvInitialPassword
)
var (
// adminStartup 是启动时解析出的管理端配置;后续 HTTP handler 和静态资源注入都会读取它。
adminStartup = adminconfig.Config{
DBPath: defaultAdminDBPath,
TCPAdminPort: defaultTCPPort,
AdminPath: defaultAdminPath,
AdminAPIPrefix: defaultAdminAPIPrefix,
SessionTTL: defaultAdminSessionTTL,
}
adminDB *sql.DB
adminDBPath string
pluginsManager *pluginmanager.Manager
processStartAt = time.Now()
)
// initializeGatewayRuntime 按固定顺序准备运行态:解析配置、打开数据库、迁移 schema、
// 写入默认服务、应用服务配置、创建初始管理员、发布路由快照、最后启动插件管理器。
func initializeGatewayRuntime() error {
startup, err := parseStartupConfig(os.Getenv)
if err != nil {
return err
}
adminStartup = startup
db, err := admindb.Open(startup.DBPath)
if err != nil {
return err
}
if adminDB != nil && adminDB != db {
_ = adminDB.Close()
}
adminDB = db
adminDBPath = startup.DBPath
if err := admindb.Migrate(db); err != nil {
return err
}
// 默认服务必须先存在applyServiceConfig 才能把 SQLite 中的运行态端口写回 config。
if err := ensureDefaultServices(context.Background(), db, startup.TCPAdminPort); err != nil {
return err
}
if err := applyServiceConfig(context.Background(), db); err != nil {
return err
}
if err := ensureInitialAdminFromEnv(context.Background(), db, os.Getenv(adminEnvInitialPassword)); err != nil {
return err
}
if err := refreshRouteSnapshot(context.Background()); err != nil {
return err
}
// 插件制品放在数据库同级目录下,便于容器挂载一个 data volume 即可保留全部运行态。
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: db,
ArtifactRoot: filepath.Join(filepath.Dir(startup.DBPath), "plugins", "artifacts"),
HandleConn: handleRequest,
WaitGroup: &exitWaitGroup,
})
return pluginsManager.Reconcile(context.Background())
}
// closeGatewayRuntime 只关闭当前进程持有的数据库连接SQLite 文件和插件制品都保留在数据目录中。
func closeGatewayRuntime() {
if adminDB != nil {
_ = adminDB.Close()
adminDB = nil
}
}
func parseStartupConfig(getenv func(string) string) (adminconfig.Config, error) {
return adminconfig.Parse(getenv)
}

View File

@@ -0,0 +1,76 @@
// cmd/gateway/admin_service_handlers.go 提供监听服务配置接口,用于维护端口、启停状态和是否需要重启。
package main
import (
"net/http"
"strings"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminservice"
)
func handleAdminServicesList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleMember); !ok {
return
}
services, err := listServiceConfigs(r.Context(), adminDB)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"services": services})
}
func handleAdminServiceItem(w http.ResponseWriter, r *http.Request, rawName string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
if strings.HasSuffix(rawName, "/restart") {
name := strings.TrimSuffix(rawName, "/restart")
if r.Method != http.MethodPost {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_restart", "service", name, false, "restart is not implemented")
adminhttp.WriteAPIError(w, http.StatusNotImplemented, "service restart is not implemented; restart the gateway process")
return
}
name, err := adminhttp.PathSegment(rawName)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
if r.Method != http.MethodPut {
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req adminhttp.ServiceRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
if req.Port == 0 {
req.Port = defaultServicePort(name)
}
err = updateServiceConfig(r.Context(), session.Username, name, enabled, req.Port, req.Options)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "service_update", "service", name, true, "service saved")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "restart_required": true})
}
func defaultServicePort(name string) int {
return adminservice.DefaultPort(name, adminStartup.TCPAdminPort)
}

View File

@@ -0,0 +1,87 @@
// cmd/gateway/admin_services.go 从 SQLite 加载监听服务配置,并暴露规范化后的运行时服务选项。
package main
import (
"context"
"database/sql"
"github.com/tursom/mc-gateway/internal/adminservice"
)
func ensureDefaultServices(ctx context.Context, db *sql.DB, tcpAdminPort int) error {
return adminservice.NewRepository(db).EnsureDefaults(ctx, tcpAdminPort)
}
func applyServiceConfig(ctx context.Context, db *sql.DB) error {
services, err := listServiceConfigs(ctx, db)
if err != nil {
return err
}
config.Tcp.Enable = true
config.Tcp.Port = adminStartup.TCPAdminPort
for _, service := range services {
switch service.Name {
case serviceNameKCP:
config.Kcp.Enable = service.Enabled
config.Kcp.Port = service.Port
config.Kcp.DataShards = adminservice.IntOption(service.Options, "data_shards", defaultKCPDataShards)
config.Kcp.ParityShards = adminservice.IntOption(service.Options, "parity_shards", defaultKCPParityShards)
case serviceNameQUIC:
config.Quic.Enable = service.Enabled
config.Quic.Port = service.Port
config.Quic.ApplicationProtocols = adminservice.StringSliceOption(service.Options, "application_protocols")
case serviceNameWebSocket:
config.WebSocket.Enable = service.Enabled
config.WebSocket.Port = service.Port
config.WebSocket.Path = adminservice.StringOption(service.Options, "path", defaultWebSocketPath)
}
}
if config.Kcp.Port == 0 {
config.Kcp.Port = defaultKCPPort
}
if config.Quic.Port == 0 {
config.Quic.Port = defaultQUICPort
}
if config.WebSocket.Port == 0 {
config.WebSocket.Port = defaultWebSocketPort
}
if config.WebSocket.Path == "" {
config.WebSocket.Path = defaultWebSocketPath
}
return nil
}
func listServiceConfigs(ctx context.Context, db *sql.DB) ([]adminservice.Record, error) {
services, err := adminservice.NewRepository(db).List(ctx)
if err != nil {
return nil, err
}
for i := range services {
services[i].Running = serviceIsRunning(services[i])
}
return services, nil
}
func serviceIsRunning(service adminservice.Record) bool {
switch service.Name {
case serviceNameTCPAdmin:
return true
case serviceNameKCP:
return config.Kcp.Enable
case serviceNameQUIC:
return config.Quic.Enable
case serviceNameWebSocket:
return config.WebSocket.Enable
default:
return false
}
}
func updateServiceConfig(ctx context.Context, actor, name string, enabled bool, port int, options map[string]any) error {
return adminservice.NewRepository(adminDB).Update(ctx, actor, name, enabled, port, options)
}

View File

@@ -0,0 +1,60 @@
// cmd/gateway/admin_sessions.go 提供 Admin API 认证中间件使用的内存会话管理器。
package main
import (
"net/http"
"strings"
"github.com/tursom/mc-gateway/internal/adminhttp"
"github.com/tursom/mc-gateway/internal/adminsession"
"github.com/tursom/mc-gateway/internal/adminuser"
)
var adminSessionManager = adminsession.NewManager()
func createSession(username, role string) (adminsession.Session, error) {
return adminSessionManager.Create(username, role, adminStartup.SessionTTL)
}
func getSession(token string) (adminsession.Session, bool) {
return adminSessionManager.Get(token)
}
func deleteSession(token string) {
adminSessionManager.Delete(token)
}
func removeSessionsForUser(username string) {
adminSessionManager.RemoveUser(username)
}
func sessionFromRequest(r *http.Request) (adminsession.Session, bool) {
auth := r.Header.Get("Authorization")
token, ok := strings.CutPrefix(auth, "Bearer ")
if !ok || strings.TrimSpace(token) == "" {
return adminsession.Session{}, false
}
return getSession(strings.TrimSpace(token))
}
func requireSession(w http.ResponseWriter, r *http.Request) (adminsession.Session, bool) {
session, ok := sessionFromRequest(r)
if !ok {
adminhttp.WriteAPIError(w, http.StatusUnauthorized, "login required")
return adminsession.Session{}, false
}
return session, true
}
func requireRole(w http.ResponseWriter, r *http.Request, role string) (adminsession.Session, bool) {
session, ok := requireSession(w, r)
if !ok {
return adminsession.Session{}, false
}
if !adminuser.HasRole(session.Role, role) {
adminhttp.WriteAPIError(w, http.StatusForbidden, "permission denied")
return adminsession.Session{}, false
}
return session, true
}

View File

@@ -0,0 +1,20 @@
// cmd/gateway/admin_static.go 嵌入构建后的管理前端,并通过网关 HTTP 处理器对外提供。
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func newGatewayHTTPHandler() http.Handler {
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
AdminPath: adminStartup.AdminPath,
AdminAPIPrefix: adminStartup.AdminAPIPrefix,
APIHandler: newAdminAPIHandler(),
WebSocketEnabled: config.WebSocket.Enable,
WebSocketPath: normalizedWebSocketPath(),
WebSocketHandler: handleWebSocket,
})
}

View File

@@ -0,0 +1,542 @@
/* cmd/gateway/admin_static/app.css 定义嵌入式管理端仪表盘、表格、表单、对话框和响应式布局样式。 */
:root {
color-scheme: light;
--bg: #f6f7f4;
--panel: #ffffff;
--text: #1c2623;
--muted: #66716c;
--line: #d9ded8;
--accent: #1f7a5d;
--accent-dark: #145944;
--warn: #a66321;
--danger: #b42318;
--shadow: 0 16px 40px rgba(21, 32, 28, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
min-height: 36px;
border: 0;
border-radius: 6px;
padding: 0 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover {
background: var(--accent-dark);
}
button.ghost,
button.secondary {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
}
button.ghost:hover,
button.secondary:hover {
border-color: #aeb8b2;
background: #eef2ef;
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: #8f1d15;
}
button.link-button {
min-height: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--accent-dark);
text-align: left;
}
button.link-button:hover {
background: transparent;
text-decoration: underline;
}
input,
select,
textarea {
width: 100%;
min-height: 38px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 10px;
background: #fff;
color: var(--text);
}
textarea {
min-height: 220px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
resize: vertical;
}
label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
label.inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
label.inline input {
width: auto;
min-height: 0;
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.topbar p {
margin: 2px 0 0;
color: var(--muted);
}
.session {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
}
.language-field {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.language-field select {
width: auto;
min-width: 104px;
}
.auth-view {
min-height: calc(100vh - 120px);
display: grid;
place-items: center;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 18px;
}
.panel.compact {
width: min(420px, 100%);
display: grid;
gap: 14px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 18px;
}
.alert {
border: 1px solid #f0c36a;
border-radius: 6px;
background: #fff7df;
color: #5f410c;
padding: 10px 12px;
margin-bottom: 14px;
}
.hidden {
display: none !important;
}
.status-grid,
.service-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat,
.service {
min-width: 0;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
}
.stat span,
.service span {
display: block;
color: var(--muted);
font-size: 12px;
}
.stat strong,
.service strong {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
font-size: 17px;
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--line);
margin: 8px 0 16px;
}
.tabs button {
border-radius: 6px 6px 0 0;
background: transparent;
color: var(--muted);
}
.tabs button.active {
background: #fff;
color: var(--text);
border: 1px solid var(--line);
border-bottom-color: #fff;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.toolbar input {
max-width: 420px;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 12px;
font-weight: 600;
background: #fafbf9;
}
td {
overflow-wrap: anywhere;
}
tr:last-child td {
border-bottom: 0;
}
tr.selected td {
background: #f0f7f3;
}
.actions {
width: 180px;
}
.row-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 999px;
padding: 0 9px;
background: #e8f3ee;
color: var(--accent-dark);
font-size: 12px;
}
.badge.off {
background: #f4ece6;
color: var(--warn);
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 9px;
background: #fff;
}
.service {
display: grid;
gap: 10px;
}
.service form,
.inline-form {
display: grid;
gap: 10px;
}
.file-button {
display: inline-flex;
align-items: center;
min-height: 36px;
border-radius: 6px;
padding: 0 14px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.file-button input {
display: none;
}
.plugin-detail {
margin-top: 16px;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.detail-header h2 {
margin: 0;
font-size: 20px;
}
.detail-header p {
margin: 2px 0 0;
color: var(--muted);
}
.status-grid.dense {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.plugin-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.plugin-layout .panel {
min-width: 0;
box-shadow: none;
}
.plugin-layout h3 {
margin: 0 0 10px;
font-size: 15px;
}
.kv {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 8px 10px;
margin: 0;
}
.kv dt {
color: var(--muted);
}
.kv dd {
margin: 0;
overflow-wrap: anywhere;
}
.mini-list {
display: grid;
gap: 8px;
}
.mini-row {
display: grid;
grid-template-columns: minmax(90px, 0.8fr) minmax(160px, 1.5fr) minmax(100px, 1fr) auto auto;
gap: 8px;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
}
.mini-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.log-output {
max-height: 240px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfb;
padding: 10px;
white-space: pre-wrap;
}
.inline-alert {
margin-bottom: 12px;
}
.empty {
color: var(--muted);
}
dialog {
width: min(460px, calc(100vw - 28px));
border: 1px solid var(--line);
border-radius: 8px;
padding: 0;
box-shadow: var(--shadow);
}
dialog::backdrop {
background: rgba(20, 27, 24, 0.36);
}
dialog form {
display: grid;
gap: 12px;
padding: 18px;
}
dialog h2 {
margin: 0;
font-size: 18px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
@media (max-width: 860px) {
.status-grid,
.service-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-grid.dense,
.plugin-layout {
grid-template-columns: 1fr;
}
.mini-row {
grid-template-columns: 1fr;
}
.topbar,
.detail-header,
.toolbar {
align-items: stretch;
flex-direction: column;
}
.session {
flex-wrap: wrap;
}
.toolbar input {
max-width: none;
}
}
@media (max-width: 560px) {
.shell {
width: min(100vw - 20px, 1180px);
padding-top: 14px;
}
.status-grid,
.service-grid {
grid-template-columns: 1fr;
}
.tabs {
overflow-x: auto;
}
}

View File

@@ -0,0 +1,239 @@
<!-- cmd/gateway/admin_static/index.html 提供嵌入式管理端 HTML 外壳,包含对话框、标签页和运行时 API 前缀注入。 -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>mc-gateway admin</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>mc-gateway</h1>
<p id="subtitle">Admin</p>
</div>
<div class="session">
<label class="language-field">
<span data-i18n="language">Language</span>
<select id="languageSelect" aria-label="Language">
<option value="en" data-i18n="languageEnglish">English</option>
<option value="zh" data-i18n="languageChinese">中文</option>
</select>
</label>
<span id="sessionUser"></span>
<button id="logoutBtn" class="ghost hidden" type="button" data-i18n="logout">Logout</button>
</div>
</header>
<section id="alert" class="alert hidden"></section>
<section id="setupView" class="auth-view hidden">
<form id="setupForm" class="panel compact">
<h2 data-i18n="initialAdmin">Initial admin</h2>
<label>
<span data-i18n="username">Username</span>
<input name="username" autocomplete="username" value="admin">
</label>
<label>
<span data-i18n="password">Password</span>
<input name="password" autocomplete="new-password" type="password" required>
</label>
<button type="submit" data-i18n="createAdmin">Create admin</button>
</form>
</section>
<section id="loginView" class="auth-view hidden">
<form id="loginForm" class="panel compact">
<h2 data-i18n="login">Login</h2>
<label>
<span data-i18n="username">Username</span>
<input name="username" autocomplete="username" required>
</label>
<label>
<span data-i18n="password">Password</span>
<input name="password" autocomplete="current-password" type="password" required>
</label>
<button type="submit" data-i18n="login">Login</button>
</form>
</section>
<section id="appView" class="hidden">
<section id="statusGrid" class="status-grid"></section>
<nav class="tabs">
<button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
<button data-tab="plugins" type="button" data-i18n="plugins">Plugins</button>
<button data-tab="services" type="button" data-i18n="services">Services</button>
<button data-tab="users" type="button" data-i18n="users">Users</button>
<button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
<button data-tab="audit" type="button" data-i18n="audit">Audit</button>
</nav>
<section id="routesTab" class="tab-panel">
<div class="toolbar">
<input id="routeSearch" placeholder="Search host, upstream, note" data-i18n-placeholder="routeSearch">
<button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="host">Host</th>
<th data-i18n="upstream">Upstream</th>
<th data-i18n="enabled">Enabled</th>
<th data-i18n="note">Note</th>
<th class="actions" data-i18n="actions">Actions</th>
</tr>
</thead>
<tbody id="routesBody"></tbody>
</table>
</div>
</section>
<section id="pluginsTab" class="tab-panel hidden">
<div class="toolbar">
<label class="file-button">
<span data-i18n="uploadPlugin">Upload plugin</span>
<input id="pluginUploadInput" type="file" accept=".mcgp">
</label>
<button id="refreshPluginsBtn" class="secondary" type="button" data-i18n="refresh">Refresh</button>
</div>
<div id="pluginServicePanel" class="plugin-service-panel"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="pluginID">Plugin ID</th>
<th data-i18n="name">Name</th>
<th data-i18n="version">Version</th>
<th data-i18n="artifact">Artifact</th>
<th data-i18n="runtime">Runtime</th>
<th data-i18n="desired">Desired</th>
<th data-i18n="active">Active</th>
<th data-i18n="desiredArtifact">Desired artifact</th>
<th data-i18n="extensions">Extensions</th>
<th data-i18n="priority">Priority</th>
<th data-i18n="restart">Restart</th>
<th data-i18n="health">Health</th>
</tr>
</thead>
<tbody id="pluginsBody"></tbody>
</table>
</div>
<div id="pluginDetail" class="plugin-detail"></div>
</section>
<section id="servicesTab" class="tab-panel hidden">
<div id="servicesGrid" class="service-grid"></div>
</section>
<section id="usersTab" class="tab-panel hidden">
<div class="toolbar">
<button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="username">Username</th>
<th data-i18n="role">Role</th>
<th data-i18n="disabled">Disabled</th>
<th class="actions" data-i18n="actions">Actions</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</section>
<section id="metricsTab" class="tab-panel hidden">
<div id="metricsGrid" class="status-grid"></div>
<div class="panel">
<h2 data-i18n="routeHits">Route hits</h2>
<div id="routeHits" class="chips"></div>
</div>
</section>
<section id="auditTab" class="tab-panel hidden">
<div class="table-wrap">
<table>
<thead>
<tr>
<th data-i18n="time">Time</th>
<th data-i18n="actor">Actor</th>
<th data-i18n="action">Action</th>
<th data-i18n="target">Target</th>
<th data-i18n="result">Result</th>
<th data-i18n="message">Message</th>
</tr>
</thead>
<tbody id="auditBody"></tbody>
</table>
</div>
</section>
</section>
</main>
<dialog id="routeDialog">
<form id="routeForm" method="dialog">
<h2 data-i18n="route">Route</h2>
<label>
<span data-i18n="host">Host</span>
<input name="host" required>
</label>
<label>
<span data-i18n="upstream">Upstream</span>
<input name="upstream" required placeholder="127.0.0.1:25565">
</label>
<label class="inline">
<input name="enabled" type="checkbox" checked>
<span data-i18n="enabled">Enabled</span>
</label>
<label>
<span data-i18n="note">Note</span>
<input name="note">
</label>
<div class="dialog-actions">
<button type="button" data-close data-i18n="cancel">Cancel</button>
<button type="submit" data-i18n="save">Save</button>
</div>
</form>
</dialog>
<dialog id="userDialog">
<form id="userForm" method="dialog">
<h2 data-i18n="user">User</h2>
<label>
<span data-i18n="username">Username</span>
<input name="username" required>
</label>
<label>
<span data-i18n="role">Role</span>
<select name="role">
<option value="admin" data-i18n="roleAdmin">Admin</option>
<option value="member" data-i18n="roleMember">Member</option>
<option value="guest" data-i18n="roleGuest">Guest</option>
</select>
</label>
<label>
<span data-i18n="password">Password</span>
<input name="password" type="password">
</label>
<label class="inline">
<input name="disabled" type="checkbox">
<span data-i18n="disabled">Disabled</span>
</label>
<div class="dialog-actions">
<button type="button" data-close data-i18n="cancel">Cancel</button>
<button type="submit" data-i18n="save">Save</button>
</div>
</form>
</dialog>
<script src="config.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
// cmd/gateway/admin_user_handlers.go 提供管理员维护管理账号的接口,包括创建、更新、禁用和列表查询。
package main
import (
"net/http"
"github.com/tursom/mc-gateway/internal/adminhttp"
)
func handleAdminUsersList(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
return
}
users, err := listUsers(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"users": users})
}
func handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
var req adminhttp.CreateUserRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
err := createUser(r.Context(), session.Username, req.Username, req.Role, req.Password, req.Disabled)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_create", "user", req.Username, true, "user created")
adminhttp.WriteJSON(w, http.StatusCreated, map[string]any{"ok": true})
}
func handleAdminUserItem(w http.ResponseWriter, r *http.Request, rawUsername string) {
session, ok := requireRole(w, r, adminRoleAdmin)
if !ok {
return
}
username, err := adminhttp.PathSegment(rawUsername)
if err != nil {
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
switch r.Method {
case http.MethodPatch:
var req adminhttp.PatchUserRequest
if !adminhttp.DecodeJSONRequest(w, r, &req) {
return
}
err := patchUser(r.Context(), session.Username, username, req.Role, req.Disabled, req.Password)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_patch", "user", username, true, "user updated")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
case http.MethodDelete:
err := deleteUser(r.Context(), username)
if err != nil {
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, false, err.Error())
adminhttp.WriteAPIError(w, http.StatusBadRequest, err.Error())
return
}
recordAudit(r.Context(), session.Username, adminhttp.RequestSourceIP(r), "user_delete", "user", username, true, "user deleted")
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
adminhttp.WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func handleAdminAuditLogs(w http.ResponseWriter, r *http.Request) {
if _, ok := requireRole(w, r, adminRoleAdmin); !ok {
return
}
logs, err := listAuditLogs(r.Context())
if err != nil {
adminhttp.WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
adminhttp.WriteJSON(w, http.StatusOK, map[string]any{"audit_logs": logs})
}

141
cmd/gateway/admin_users.go Normal file
View File

@@ -0,0 +1,141 @@
// cmd/gateway/admin_users.go 初始化管理用户仓库,并在没有账号时创建首次初始化用户。
package main
import (
"context"
"database/sql"
"errors"
"strings"
"github.com/tursom/mc-gateway/internal/adminuser"
"golang.org/x/crypto/bcrypt"
)
const (
adminRoleAdmin = adminuser.RoleAdmin
adminRoleMember = adminuser.RoleMember
adminRoleGuest = adminuser.RoleGuest
)
func ensureInitialAdminFromEnv(ctx context.Context, db *sql.DB, password string) error {
if strings.TrimSpace(password) == "" {
return nil
}
empty, err := usersTableEmpty(ctx, db)
if err != nil {
return err
}
if !empty {
return nil
}
return createUser(ctx, "system", "admin", adminRoleAdmin, password, false)
}
func usersTableEmpty(ctx context.Context, db *sql.DB) (bool, error) {
return adminuser.NewRepository(db).TableEmpty(ctx)
}
func createInitialAdmin(ctx context.Context, username, password string) error {
empty, err := usersTableEmpty(ctx, adminDB)
if err != nil {
return err
}
if !empty {
return errors.New("initial admin has already been created")
}
return createUser(ctx, "setup", username, adminRoleAdmin, password, false)
}
func createUser(ctx context.Context, actor, username, role, password string, disabled bool) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
if err := adminuser.ValidateRole(role); err != nil {
return err
}
if err := adminuser.ValidatePassword(password); err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return err
}
_ = actor
return adminuser.NewRepository(adminDB).Create(ctx, username, role, hash, disabled)
}
func authenticateUser(ctx context.Context, username, password string) (adminuser.User, error) {
user, hash, err := getUserWithHash(ctx, username)
if err != nil {
return adminuser.User{}, err
}
if user.Disabled {
return adminuser.User{}, errors.New("user is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return adminuser.User{}, errors.New("invalid username or password")
}
return user, nil
}
func getUserWithHash(ctx context.Context, username string) (adminuser.User, string, error) {
return adminuser.NewRepository(adminDB).GetWithHash(ctx, username)
}
func listUsers(ctx context.Context) ([]adminuser.User, error) {
return adminuser.NewRepository(adminDB).List(ctx)
}
func patchUser(ctx context.Context, actor, username string, role *string, disabled *bool, password *string) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
var passwordHashProvider func() (string, error)
if password != nil {
passwordValue := *password
passwordHashProvider = func() (string, error) {
if err := adminuser.ValidatePassword(passwordValue); err != nil {
return "", err
}
return hashPassword(passwordValue)
}
}
result, err := adminuser.NewRepository(adminDB).Patch(ctx, username, adminuser.Patch{
Role: role,
Disabled: disabled,
PasswordHashProvider: passwordHashProvider,
})
if err != nil {
return err
}
if result.InvalidateSessions {
removeSessionsForUser(username)
}
_ = actor
return nil
}
func deleteUser(ctx context.Context, username string) error {
username = strings.TrimSpace(username)
if err := adminuser.ValidateUsername(username); err != nil {
return err
}
if err := adminuser.NewRepository(adminDB).Delete(ctx, username); err != nil {
return err
}
removeSessionsForUser(username)
return nil
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}

40
cmd/gateway/config.go Normal file
View File

@@ -0,0 +1,40 @@
// cmd/gateway/config.go 加载静态网关配置,并与管理数据库提供的运行态状态组合使用。
package main
import (
"sync"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
)
var (
config gatewayconfig.Config
configLoadLock sync.Mutex
)
func loadConfig() error {
configLoadLock.Lock()
defer configLoadLock.Unlock()
if err := initializeGatewayRuntime(); err != nil {
return err
}
if err := loadLogger(); err != nil {
return err
}
loadPlugins()
return nil
}
func loadPluginConfig(cfg map[string]any, pluginCfg any) error {
log.Info().
Any("config", cfg).
Msg("Loading plugin config")
return gatewayconfig.DecodePluginConfig(cfg, pluginCfg)
}

203
cmd/gateway/config_test.go Normal file
View File

@@ -0,0 +1,203 @@
// cmd/gateway/config_test.go 包含用于约束 config 行为的测试。
package main
import (
"path/filepath"
"testing"
)
func TestLoadPluginConfig(t *testing.T) {
defer saveGatewayState(t)()
type pluginConfig struct {
Enable bool `toml:"enable"`
Name string `toml:"name"`
Count int `toml:"count"`
}
var got pluginConfig
err := loadPluginConfig(map[string]any{
"enable": true,
"name": "plugin-a",
"count": 7,
}, &got)
if err != nil {
t.Fatalf("loadPluginConfig() error = %v", err)
}
want := pluginConfig{Enable: true, Name: "plugin-a", Count: 7}
if got != want {
t.Fatalf("loadPluginConfig() = %+v, want %+v", got, want)
}
}
func TestLoadPluginConfigReturnsDecodeError(t *testing.T) {
defer saveGatewayState(t)()
type pluginConfig struct {
Count int `toml:"count"`
}
var got pluginConfig
if err := loadPluginConfig(map[string]any{"count": "not-an-int"}, &got); err == nil {
t.Fatal("loadPluginConfig() error = nil, want error")
}
}
func TestParseStartupConfigDefaultsAndEnv(t *testing.T) {
cfg, err := parseStartupConfig(func(string) string { return "" })
if err != nil {
t.Fatalf("parseStartupConfig() error = %v", err)
}
if cfg.DBPath != defaultAdminDBPath {
t.Fatalf("DBPath = %q, want %q", cfg.DBPath, defaultAdminDBPath)
}
if cfg.TCPAdminPort != defaultTCPPort {
t.Fatalf("TCPAdminPort = %d, want %d", cfg.TCPAdminPort, defaultTCPPort)
}
if cfg.AdminPath != defaultAdminPath {
t.Fatalf("AdminPath = %q, want %q", cfg.AdminPath, defaultAdminPath)
}
if cfg.AdminAPIPrefix != defaultAdminAPIPrefix {
t.Fatalf("AdminAPIPrefix = %q, want %q", cfg.AdminAPIPrefix, defaultAdminAPIPrefix)
}
env := map[string]string{
adminEnvDB: "/tmp/mc.db",
adminEnvTCPAdminPort: "25575",
adminEnvPath: "/ops",
adminEnvAPIPrefix: "/ops/api/",
}
cfg, err = parseStartupConfig(func(key string) string { return env[key] })
if err != nil {
t.Fatalf("parseStartupConfig(env) error = %v", err)
}
if cfg.DBPath != "/tmp/mc.db" || cfg.TCPAdminPort != 25575 || cfg.AdminPath != "/ops/" || cfg.AdminAPIPrefix != "/ops/api" {
t.Fatalf("startup config = %+v", cfg)
}
}
func TestParseStartupConfigReturnsErrors(t *testing.T) {
tests := []struct {
name string
env map[string]string
}{
{
name: "invalid port",
env: map[string]string{adminEnvTCPAdminPort: "70000"},
},
{
name: "invalid admin path",
env: map[string]string{adminEnvPath: "admin"},
},
{
name: "invalid api prefix",
env: map[string]string{adminEnvAPIPrefix: "api"},
},
{
name: "api prefix equals admin path",
env: map[string]string{
adminEnvPath: "/admin",
adminEnvAPIPrefix: "/admin",
},
},
{
name: "api prefix under js asset path",
env: map[string]string{adminEnvAPIPrefix: "/admin/js/api"},
},
{
name: "api prefix under config asset path",
env: map[string]string{adminEnvAPIPrefix: "/admin/config.js/api"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseStartupConfig(func(key string) string { return tt.env[key] })
if err == nil {
t.Fatal("parseStartupConfig() error = nil, want error")
}
})
}
}
func TestLoadConfigInitializesSQLiteDefaults(t *testing.T) {
defer saveGatewayState(t)()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "gateway.sqlite3")
t.Setenv(adminEnvDB, dbPath)
t.Setenv(adminEnvTCPAdminPort, "25575")
t.Setenv(adminEnvPath, "/ops")
t.Setenv(adminEnvAPIPrefix, "/ops/api")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if !config.Tcp.Enable || config.Tcp.Port != 25575 {
t.Fatalf("tcp config = %+v", config.Tcp)
}
if config.Quic.Enable || config.Kcp.Enable || config.WebSocket.Enable {
t.Fatalf("optional services should be disabled by default: quic=%+v kcp=%+v websocket=%+v", config.Quic, config.Kcp, config.WebSocket)
}
if config.Kcp.Port != defaultKCPPort || config.Kcp.DataShards != defaultKCPDataShards || config.Kcp.ParityShards != defaultKCPParityShards {
t.Fatalf("kcp config = %+v", config.Kcp)
}
if adminStartup.DBPath != dbPath || adminStartup.AdminPath != "/ops/" || adminStartup.AdminAPIPrefix != "/ops/api" {
t.Fatalf("adminStartup = %+v", adminStartup)
}
if adminDB == nil {
t.Fatal("adminDB = nil")
}
var enabled, port int
if err := adminDB.QueryRow(`SELECT enabled, port FROM services WHERE name = ?`, serviceNameTCPAdmin).Scan(&enabled, &port); err != nil {
t.Fatalf("query tcp_admin service: %v", err)
}
if enabled != 1 || port != 25575 {
t.Fatalf("tcp_admin service enabled=%d port=%d, want enabled=1 port=25575", enabled, port)
}
}
func TestLoadConfigCreatesInitialAdminFromEnv(t *testing.T) {
defer saveGatewayState(t)()
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvInitialPassword, "secret")
if err := loadConfig(); err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
user, err := authenticateUser(t.Context(), "admin", "secret")
if err != nil {
t.Fatalf("authenticateUser() error = %v", err)
}
if user.Role != adminRoleAdmin {
t.Fatalf("admin role = %q, want %q", user.Role, adminRoleAdmin)
}
}
func TestLoadConfigReturnsEnvErrors(t *testing.T) {
t.Run("invalid port", func(t *testing.T) {
defer saveGatewayState(t)()
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvTCPAdminPort, "0")
if err := loadConfig(); err == nil {
t.Fatal("loadConfig() error = nil, want error")
}
})
t.Run("invalid admin path", func(t *testing.T) {
defer saveGatewayState(t)()
t.Setenv(adminEnvDB, filepath.Join(t.TempDir(), "gateway.sqlite3"))
t.Setenv(adminEnvPath, "admin")
if err := loadConfig(); err == nil {
t.Fatal("loadConfig() error = nil, want error")
}
})
}

9
cmd/gateway/err.go Normal file
View File

@@ -0,0 +1,9 @@
// cmd/gateway/err.go 集中放置网关请求路径使用的少量哨兵错误。
package main
import "errors"
var (
errEmptyBuffer = errors.New("empty buffer")
)

View File

@@ -0,0 +1,385 @@
// cmd/gateway/handle_request_test.go 包含用于约束 handle request 行为的测试。
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"strings"
"testing"
"time"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
)
func TestHandleRequestProxiesAndClosesConnections(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example")
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn([]byte("reply"))
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) {
return upstream, nil
},
)
handleRequest(source)
if !source.closed {
t.Fatal("source connection was not closed")
}
if !upstream.closed {
t.Fatal("upstream connection was not closed")
}
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
}
if got := source.writeBuf.String(); got != "reply" {
t.Fatalf("proxied reply = %q, want reply", got)
}
}
func TestHandleRequestProtocolProxyReplaysInitialDataOnce(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example", 0x63, 0x02)
source := newGatewayTestConn(packet)
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
if req.ServerHost != "play.example" || req.Host != "play.example" {
t.Fatalf("request host = %q/%q, want play.example", req.ServerHost, req.Host)
}
if req.ProtocolVersion != 0x63 || req.NextState != 0x02 {
t.Fatalf("protocol/next state = %d/%d, want 99/2", req.ProtocolVersion, req.NextState)
}
if !bytes.Equal(req.InitialData, packet) {
t.Fatalf("initial data = %v, want %v", req.InitialData, packet)
}
gatewayEnd, pluginEnd := net.Pipe()
go func() {
defer pluginEnd.Close()
buf := make([]byte, len(packet))
if _, err := io.ReadFull(pluginEnd, buf); err != nil {
t.Errorf("plugin endpoint ReadFull() error = %v", err)
return
}
if !bytes.Equal(buf, packet) {
t.Errorf("plugin endpoint initial data = %v, want %v", buf, packet)
return
}
if host := protocol.GetMcHost(buf); host != "play.example" {
t.Errorf("plugin endpoint host = %q, want play.example", host)
return
}
_, _ = pluginEnd.Write([]byte("login rejected"))
}()
return gatewayEnd, nil
}},
})
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
handleRequest(source)
if got := source.writeBuf.String(); got != "login rejected" {
t.Fatalf("source response = %q, want login rejected", got)
}
plan := pluginsManager.DispatchPlan(context.Background())
if len(plan.Handlers) != 1 {
t.Fatalf("dispatch handlers = %d, want 1", len(plan.Handlers))
}
if plan.Handlers[0].Mode != pluginmanager.UpstreamModeProtocolProxy {
t.Fatalf("handler mode = %q, want protocol-proxy", plan.Handlers[0].Mode)
}
if plan.Handlers[0].ProxyStarted != 1 || plan.Handlers[0].ProxyCompleted != 1 {
t.Fatalf("proxy lifecycle = started %d completed %d, want 1/1", plan.Handlers[0].ProxyStarted, plan.Handlers[0].ProxyCompleted)
}
}
func TestHandleRequestManagedErrBlockedClosesSource(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(gatewayTestPacket("play.example"))
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
return nil, api.ErrBlocked
}},
})
artifact := uploadGatewayTestArtifact(t, pluginsManager, "blocked-plugin")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "blocked-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "blocked-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
handleRequest(source)
if !source.closed {
t.Fatal("source was not closed")
}
if source.writeBuf.Len() != 0 {
t.Fatalf("source response length = %d, want 0", source.writeBuf.Len())
}
}
func TestHandleRequestProtocolProxyPanicOnlyFailsCurrentConnection(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
calls := 0
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(api.UpstreamConnectRequest) (net.Conn, error) {
calls++
if calls == 1 {
panic("boom")
}
upstream := newGatewayTestConn(nil)
upstream.writeBuf.WriteString("ok")
return upstream, nil
}},
})
artifact := uploadGatewayTestArtifact(t, pluginsManager, "panic-plugin")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "panic-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "panic-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
first := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(first)
if !first.closed {
t.Fatal("first connection was not closed")
}
second := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(second)
if !second.closed {
t.Fatal("second connection was not closed")
}
plan := pluginsManager.DispatchPlan(context.Background())
if got := plan.Handlers[0].Panics; got != 1 {
t.Fatalf("panic count = %d, want 1", got)
}
if calls != 2 {
t.Fatalf("handler calls = %d, want 2", calls)
}
}
func TestHandleRequestProtocolProxyDisableSkipsNewConnections(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
proxyCalls := 0
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
proxyCalls++
gatewayEnd, pluginEnd := net.Pipe()
initialLen := len(req.InitialData)
go func() {
defer pluginEnd.Close()
_, _ = io.ReadFull(pluginEnd, make([]byte, initialLen))
}()
return gatewayEnd, nil
}},
})
artifact := uploadGatewayTestArtifactWithCapabilities(t, pluginsManager, "proxy-plugin", gatewayProtocolProxyCapabilities())
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "proxy-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
approveGatewayPluginGovernanceForTest(t, "proxy-plugin", artifact.ID)
if _, err := pluginsManager.Enable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
first := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(first)
if proxyCalls != 1 {
t.Fatalf("proxy calls after first request = %d, want 1", proxyCalls)
}
if _, err := pluginsManager.Disable(context.Background(), "admin", "proxy-plugin"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
legacyUpstream := newGatewayTestConn(nil)
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
)
second := newGatewayTestConn(gatewayTestPacket("play.example"))
handleRequest(second)
if proxyCalls != 1 {
t.Fatalf("proxy calls after disable = %d, want still 1", proxyCalls)
}
if legacyUpstream.writeBuf.Len() == 0 {
t.Fatal("legacy upstream did not receive second request")
}
}
func TestHandleRequestRouteResolverUsesOverrideAndSQLiteFallback(t *testing.T) {
defer saveGatewayState(t)()
setGatewayTestRoutes(map[string]string{"fallback.example": "fallback-upstream:25565"})
var dialed []string
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
return api.RegisterHookHandler(gateway, api.HookRouteResolve,
func(api.RouteResolveRequest) bool { return true },
func(req api.RouteResolveRequest) (api.RouteDecision, error) {
if req.Host == "override.example" {
return api.RouteDecision{Action: api.RouteDecisionOverride, Upstream: "override-upstream:25565", CacheTTL: time.Minute}, nil
}
return api.RouteDecision{Action: api.RouteDecisionPass}, nil
})
}},
})
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "route-plugin", func(manifest *pluginmanager.Manifest) {
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "provider", Key: pluginmanager.ExtensionRouteResolve}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["route.resolve/v1"]}`)
})
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "route-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "route-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
registerGatewayUpstreamHook(t, func(net.Conn, string) bool { return true }, func(_ net.Conn, host string) (net.Conn, error) {
dialed = append(dialed, host)
return newGatewayTestConn(nil), nil
})
handleRequest(newGatewayTestConn(gatewayTestPacket("override.example")))
handleRequest(newGatewayTestConn(gatewayTestPacket("fallback.example")))
if len(dialed) != 2 || dialed[0] != "override-upstream:25565" || dialed[1] != "fallback-upstream:25565" {
t.Fatalf("dialed = %+v, want override then sqlite fallback", dialed)
}
}
func TestHandleRequestStatusPingPluginRespondsPerHost(t *testing.T) {
defer saveGatewayState(t)()
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{initHook: func(gateway *pluginmanager.Gateway) error {
return api.RegisterHookHandler(gateway, api.HookStatusPing,
func(api.StatusPingRequest) bool { return true },
func(req api.StatusPingRequest) (api.StatusPingResponse, error) {
return api.StatusPingResponse{MOTD: "hello " + req.Host, VersionText: "phase7", MaxPlayers: 100}, nil
})
}},
})
artifact := uploadGatewayTestArtifactWithManifest(t, pluginsManager, "status-plugin", func(manifest *pluginmanager.Manifest) {
manifest.ExtensionPoints = []pluginmanager.ExtensionPoint{{Type: "hook", Key: pluginmanager.ExtensionStatusPing}}
manifest.Capabilities = json.RawMessage(`{"extension_points":["status.ping/v1"]}`)
})
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "status-plugin", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "status-plugin"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
source := newGatewayTestConn(gatewayTestPacket("status.example", 0x63, 0x01))
handleRequest(source)
if got := source.writeBuf.String(); !strings.Contains(got, "hello status.example") {
t.Fatalf("status response = %q, want host MOTD", got)
}
}
func TestHandleRequestRecoversAndClosesConnection(t *testing.T) {
defer saveGatewayState(t)()
source := &panicReadGatewayConn{gatewayTestConn: newGatewayTestConn(nil)}
handleRequest(source)
if !source.closed {
t.Fatal("source connection was not closed after panic")
}
}
func TestGatewayHandleConnStartsRequestGoroutine(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(nil)
source.readErr = errors.New("read failed")
(&Gateway{}).HandleConn(source)
deadline := time.After(2 * time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for {
select {
case <-deadline:
t.Fatal("timed out waiting for HandleConn goroutine")
case <-ticker.C:
if source.isClosed() {
return
}
}
}
}
func TestGatewayTestOpPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("TestOp() did not panic")
}
}()
(&Gateway{}).TestOp()
}
type panicReadGatewayConn struct {
*gatewayTestConn
}
func (c *panicReadGatewayConn) Read([]byte) (int, error) {
panic("read panic")
}

59
cmd/gateway/haproxy.go Normal file
View File

@@ -0,0 +1,59 @@
// cmd/gateway/haproxy.go 为需要 HAProxy PROXY 头的上游 TCP 连接先写入代理头,再回放 Minecraft 流量。
package main
import (
"net"
proxyproto "github.com/pires/go-proxyproto"
"github.com/rs/zerolog/log"
)
func haProxyUpstream(source net.Conn, host string) net.Conn {
target, err := net.ResolveTCPAddr("tcp", host)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Msg("failed to resolve TCP address")
return nil
}
conn, err := tcpDialer.Dial("tcp", target.String())
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Msg("failed to dial TCP")
return nil
}
setSocketOptions(conn)
sourceAddr, err := net.ResolveTCPAddr(
source.RemoteAddr().Network(),
source.RemoteAddr().String(),
)
if err != nil {
log.Err(err).Msg("failed to resolve TCP address")
conn.Close()
return nil
}
TransportProtocol := proxyproto.TCPv4
if sourceAddr.IP.To4() == nil {
TransportProtocol = proxyproto.TCPv6
}
header := &proxyproto.Header{
Version: 1,
Command: proxyproto.PROXY,
TransportProtocol: TransportProtocol,
SourceAddr: sourceAddr,
DestinationAddr: target,
}
// 连接建立后先写入 PROXY 头,再转发 Minecraft 首包。
_, err = header.WriteTo(conn)
if err != nil {
log.Err(err).Msg("failed to write proxy header")
conn.Close()
return nil
}
return conn
}

View File

@@ -0,0 +1,70 @@
// cmd/gateway/haproxy_test.go 包含用于约束 haproxy 行为的测试。
package main
import (
"bufio"
"net"
"strings"
"testing"
"time"
)
func TestHaProxyUpstreamWritesProxyHeader(t *testing.T) {
defer saveGatewayState(t)()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
defer listener.Close()
headerCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
defer conn.Close()
header, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
errCh <- err
return
}
headerCh <- header
}()
source := newGatewayTestConn(nil)
source.remote = &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 45678}
conn := haProxyUpstream(source, listener.Addr().String())
if conn == nil {
t.Fatal("haProxyUpstream() = nil, want connection")
}
defer conn.Close()
select {
case header := <-headerCh:
if !strings.HasPrefix(header, "PROXY TCP4 127.0.0.2 127.0.0.1 45678 ") {
t.Fatalf("proxy header = %q", header)
}
if !strings.HasSuffix(header, "\r\n") {
t.Fatalf("proxy header missing CRLF: %q", header)
}
case err := <-errCh:
t.Fatalf("accept/read header error = %v", err)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for proxy header")
}
}
func TestHaProxyUpstreamReturnsNilForInvalidHost(t *testing.T) {
defer saveGatewayState(t)()
if got := haProxyUpstream(newGatewayTestConn(nil), "not a tcp address"); got != nil {
t.Fatalf("haProxyUpstream() = %v, want nil", got)
}
}

69
cmd/gateway/kcp.go Normal file
View File

@@ -0,0 +1,69 @@
// cmd/gateway/kcp.go 启动可选的 KCP 监听器,并把接收到的会话转入统一网关请求处理流程。
package main
import (
"fmt"
"net"
"sync"
"github.com/rs/zerolog/log"
"github.com/xtaci/kcp-go"
)
func runKcp(wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
// KCP 监听使用运行态服务配置中的分片参数,和上游拨号保持一致。
listener, err := kcp.ListenWithOptions(fmt.Sprintf(":%d", config.Kcp.Port), nil, config.Kcp.DataShards, config.Kcp.ParityShards)
if err != nil {
log.Fatal().Err(err).
Int("port", config.Kcp.Port).
Msg("Failed to listen on KCP port")
}
defer listener.Close()
log.Info().Int("port", config.Kcp.Port).Msg("KCP server is listening")
for {
conn, err := listener.AcceptKCP()
if err != nil {
log.Err(err).
Msg("Failed to accept KCP connection")
continue
}
log.Debug().
Str("remote_addr", conn.RemoteAddr().String()).
Msg("Accepted KCP connection")
tuneKcpConn(conn)
// KCP session 实现 net.Conn可以直接进入统一网关请求流程。
go handleRequest(conn)
}
}
func upstreamKcp(host string) net.Conn {
// KCP 上游使用与入口相同的 data/parity shards确保两端编码参数匹配。
conn, err := kcp.DialWithOptions(host, nil, config.Kcp.DataShards, config.Kcp.ParityShards)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Error().Err(err).
Msg("Failed to dial KCP server")
return nil
}
tuneKcpConn(conn)
return conn
}
func tuneKcpConn(conn *kcp.UDPSession) {
// 这里偏向低延迟交互stream mode 模拟 TCP 字节流,禁用写延迟并打开快速 ACK。
conn.SetStreamMode(true)
conn.SetWriteDelay(false)
conn.SetNoDelay(1, 10, 2, 1)
conn.SetWindowSize(256, 256)
conn.SetACKNoDelay(true)
}

66
cmd/gateway/log.go Normal file
View File

@@ -0,0 +1,66 @@
// cmd/gateway/log.go 配置网关日志、日志文件、日志级别和日志轮转钩子。
package main
import (
"io"
"os"
"path/filepath"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var (
currentLogFile string
)
func loadLogger() error {
if len(config.Log.Level) == 0 {
config.Log.Level = "info"
}
level, err := zerolog.ParseLevel(config.Log.Level)
if err != nil {
return err
}
log.Logger = log.Logger.Level(level)
if config.Log.File != "" && currentLogFile != config.Log.File {
if err := os.MkdirAll(filepath.Dir(config.Log.File), 0755); err != nil {
return err
}
logFile, err := os.OpenFile(config.Log.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
log.Logger = log.Logger.Output(io.MultiWriter(os.Stdout, logFile))
currentLogFile = config.Log.File
}
return nil
}
func reopenLogFile() error {
configLoadLock.Lock()
defer configLoadLock.Unlock()
if config.Log.File == "" {
return nil
}
// 重新打开日志文件
logFile, err := os.OpenFile(config.Log.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
log.Logger = log.Logger.Output(io.MultiWriter(os.Stdout, logFile))
currentLogFile = config.Log.File
return nil
}

View File

@@ -0,0 +1,17 @@
// cmd/gateway/log_notunix.go 在没有 Unix 信号的平台上提供空的日志轮转信号钩子。
// pid_unix.go
//go:build !unix && !plan9
package main
import (
"github.com/rs/zerolog/log"
)
func handleLogRotate() {
// 非 Unix 平台不执行日志轮转信号处理。
// 该平台不支持通过信号触发日志轮转。
// 保留空实现是为了让跨平台调用点保持一致。
log.Info().Msg("Log rotation is not supported on this platform")
}

139
cmd/gateway/log_pid_test.go Normal file
View File

@@ -0,0 +1,139 @@
// cmd/gateway/log_pid_test.go 包含用于约束 log pid 行为的测试。
package main
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func TestWritePIDFileCreatesAndSwitchesFiles(t *testing.T) {
defer saveGatewayState(t)()
tmpDir := t.TempDir()
firstPID := filepath.Join(tmpDir, "first.pid")
secondPID := filepath.Join(tmpDir, "second.pid")
config.PidFile = firstPID
if err := writePIDFile(); err != nil {
t.Fatalf("writePIDFile(first) error = %v", err)
}
assertPIDFile(t, firstPID)
config.PidFile = secondPID
if err := writePIDFile(); err != nil {
t.Fatalf("writePIDFile(second) error = %v", err)
}
if _, err := os.Stat(firstPID); !os.IsNotExist(err) {
t.Fatalf("first pid file still exists or stat failed: %v", err)
}
assertPIDFile(t, secondPID)
removePIDFile()
if _, err := os.Stat(secondPID); !os.IsNotExist(err) {
t.Fatalf("second pid file still exists or stat failed: %v", err)
}
}
func TestWritePIDFileNoopsWhenPathUnchanged(t *testing.T) {
defer saveGatewayState(t)()
pidPath := filepath.Join(t.TempDir(), "gateway.pid")
config.PidFile = pidPath
if err := writePIDFile(); err != nil {
t.Fatalf("writePIDFile() error = %v", err)
}
firstStat, err := os.Stat(pidPath)
if err != nil {
t.Fatalf("Stat(first) error = %v", err)
}
if err := writePIDFile(); err != nil {
t.Fatalf("writePIDFile() second error = %v", err)
}
secondStat, err := os.Stat(pidPath)
if err != nil {
t.Fatalf("Stat(second) error = %v", err)
}
if !firstStat.ModTime().Equal(secondStat.ModTime()) {
t.Fatalf("pid file mod time changed: %v -> %v", firstStat.ModTime(), secondStat.ModTime())
}
}
func TestLoadLoggerDefaultLevelAndInvalidLevel(t *testing.T) {
defer saveGatewayState(t)()
if err := loadLogger(); err != nil {
t.Fatalf("loadLogger() error = %v", err)
}
if config.Log.Level != "info" {
t.Fatalf("default log level = %q, want info", config.Log.Level)
}
if got := log.Logger.GetLevel(); got != zerolog.InfoLevel {
t.Fatalf("logger level = %v, want %v", got, zerolog.InfoLevel)
}
config.Log.Level = "not-a-level"
if err := loadLogger(); err == nil {
t.Fatal("loadLogger() error = nil, want invalid level error")
}
}
func TestLoadLoggerCreatesLogFile(t *testing.T) {
defer saveGatewayState(t)()
logPath := filepath.Join(t.TempDir(), "nested", "gateway.log")
config.Log.Level = "debug"
config.Log.File = logPath
if err := loadLogger(); err != nil {
t.Fatalf("loadLogger() error = %v", err)
}
if currentLogFile != logPath {
t.Fatalf("currentLogFile = %q, want %q", currentLogFile, logPath)
}
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("Stat(log file) error = %v", err)
}
if got := log.Logger.GetLevel(); got != zerolog.DebugLevel {
t.Fatalf("logger level = %v, want %v", got, zerolog.DebugLevel)
}
}
func TestReopenLogFile(t *testing.T) {
defer saveGatewayState(t)()
if err := reopenLogFile(); err != nil {
t.Fatalf("reopenLogFile(empty) error = %v", err)
}
logPath := filepath.Join(t.TempDir(), "gateway.log")
config.Log.File = logPath
if err := reopenLogFile(); err != nil {
t.Fatalf("reopenLogFile() error = %v", err)
}
if currentLogFile != logPath {
t.Fatalf("currentLogFile = %q, want %q", currentLogFile, logPath)
}
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("Stat(log file) error = %v", err)
}
}
func assertPIDFile(t *testing.T, path string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", path, err)
}
want := fmt.Sprintf("%d\n", os.Getpid())
if string(got) != want {
t.Fatalf("pid file %s = %q, want %q", path, string(got), want)
}
}

26
cmd/gateway/log_unix.go Normal file
View File

@@ -0,0 +1,26 @@
// cmd/gateway/log_unix.go 注册 Unix 信号处理,让进程无需完整重启即可重新打开日志文件。
// pid_unix.go
//go:build unix || plan9
package main
import (
"os"
"os/signal"
"syscall"
"github.com/rs/zerolog/log"
)
func handleLogRotate() {
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGHUP)
for range signalChan {
log.Info().Msg("Received SIGHUP, reopening log file")
if err := reopenLogFile(); err != nil {
log.Error().Err(err).Msg("Failed to reopen log file")
}
}
}

View File

@@ -1,170 +1,429 @@
// cmd/gateway/main.go 负责网关进程启动、监听器选择、Minecraft 握手路由、插件钩子分发以及上游转发交接。
package main
import (
"encoding/json"
"fmt"
"io"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net"
"os"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"github.com/tursom/mc-gateway/internal/upstreamtarget"
"github.com/tursom/mc-gateway/plugin/api"
"github.com/tursom/mc-gateway/protocol"
)
type Config struct {
Port int `json:"port"`
Hosts map[string]string `json:"hosts"`
Default string `json:"default"`
}
var config Config
func loadConfig() error {
file, err := os.Open("config.json")
if err != nil {
return err
}
defer file.Close()
byteValue, err := io.ReadAll(file)
if err != nil {
return err
}
return json.Unmarshal(byteValue, &config)
}
func watchConfig() *fsnotify.Watcher {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal().Err(err).Msg("Failed to create config watcher")
}
go func() {
for {
_, ok := <-watcher.Events
if !ok {
return
}
loadConfig()
}
}()
err = watcher.Add("config.json")
if err != nil {
log.Fatal().Err(err).Msg("Failed to watch config file")
}
return watcher
}
func main() {
// 插件子命令复用网关二进制。这里先于运行态配置加载处理它们,
// 这样本地构建和清单命令不需要一份可用的网关部署配置。
if handled, code := runPluginCLI(os.Args[1:]); handled {
os.Exit(code)
}
if err := loadConfig(); err != nil {
panic(err)
}
watcher := watchConfig()
defer watcher.Close()
if err := writePIDFile(); err != nil {
log.Err(err).Msg("Failed to write PID file")
}
defer removePIDFile()
defer closeGatewayRuntime()
// 监听TCP端口
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.Port))
if err != nil {
panic(err)
}
defer listener.Close()
log.Info().
Int("port", config.Port).
Msg("Listening")
go handleLogRotate()
for {
// 接受传入的连接
conn, err := listener.Accept()
if err != nil {
log.Err(err).Msg("Error accepting")
continue
defer exitWaitGroup.Wait()
startEnabledServices()
}
// 处理连接
go handleRequest(conn)
func startEnabledServices() {
// TCP 和 Admin HTTP 始终通过共享监听器启动。共享监听器按每条连接
// 的首包判断它是 HTTP 还是 Minecraft 协议数据,因此不需要额外维护
// 一个手动模式开关。
startService(runTcpWebPortReuse)
// 可选传输最终仍进入 handleRequest这让插件过滤、路由解析和上游拨号
// 在 TCP、KCP、QUIC 和 WebSocket 入口之间保持一致。
if config.Kcp.Enable {
startService(runKcp)
}
if config.Quic.Enable {
startService(runQuic)
}
if config.WebSocket.Enable && normalizedWebSocketPort() != normalizedTCPPort() {
startService(runWebSocket)
}
}
func startService(run func(wg *sync.WaitGroup)) {
exitWaitGroup.Add(1)
go run(&exitWaitGroup)
}
func handleRequest(conn net.Conn) {
gatewayMetrics.ConnectionStarted()
defer gatewayMetrics.ConnectionFinished()
// 插件或协议解析器的 panic 不能杀掉监听协程;当前连接会被放弃,
// 进程继续服务其他客户端。
defer func() {
rec := recover()
if rec == nil {
return
}
if err, ok := rec.(error); ok {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("Panic on handle request")
} else {
log.Error().Any("err", rec).
Str("client", conn.RemoteAddr().String()).
Msg("Panic on handle request")
}
}()
// 确保连接关闭
defer conn.Close()
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
log.Err(err).Msg("Error reading hostname")
return
}
mc_host := GetMcHost(buf[:n])
host, ok := config.Hosts[mc_host]
if !ok {
host = config.Default
}
client, err := net.Dial("tcp", host)
if err != nil {
log.Err(err).Msg("Error dialing")
client := mapToHost(conn)
if client == nil {
return
}
defer client.Close()
client.Write(buf[:n])
var wg sync.WaitGroup
wg.Add(2)
go handleRead(client, conn, &wg)
go handleWrite(client, conn, &wg)
wg.Wait()
proxyConnections(conn, client)
}
func handleRead(srv, cli net.Conn, wg *sync.WaitGroup) {
defer func() {
wg.Done()
srv.Close()
cli.Close()
}()
buf := make([]byte, 1024)
for {
n, err := srv.Read(buf)
func mapToHost(conn net.Conn) net.Conn {
// 连接过滤器在读取 Minecraft 握手前执行,因此可以按来源地址或传输类型
// 拒绝连接,同时不消耗客户端发送的协议字节。
if pluginsManager != nil {
transport, _, _ := connectionIngress(conn)
filter, err := pluginsManager.FilterConnection(context.Background(), api.ConnectionFilterRequest{
SourceAddr: conn.RemoteAddr().String(),
Transport: transport,
})
if err != nil {
return
log.Err(err).Str("client", conn.RemoteAddr().String()).Msg("connection filter failed")
return nil
}
cli.Write(buf[:n])
if !filter.Allowed {
log.Info().Str("client", conn.RemoteAddr().String()).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("connection rejected by filter")
return nil
}
}
func handleWrite(srv, cli net.Conn, wg *sync.WaitGroup) {
defer func() {
wg.Done()
srv.Close()
cli.Close()
}()
buf := getProxyBuffer()
defer putProxyBuffer(buf)
buf := make([]byte, 1024)
for {
n, err := cli.Read(buf)
n, err := conn.Read(buf)
if err != nil {
return
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("failed to reading hostname")
return nil
}
if n == 0 {
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Msg("buffer is empty")
return nil
}
srv.Write(buf[:n])
// 第一次读取包含 Minecraft 握手数据。所有过滤器和路由决策完成后,
// 这段数据必须原样或按插件改写后回放给选中的上游。
initialData := append([]byte(nil), buf[:n]...)
handshake := protocol.ParseHandshake(initialData)
if handshake.ServerHost == "" {
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Msg("failed to parse mc host from buffer")
return nil
}
// 握手过滤器可以改写目标主机名。发生改写时要立刻重建首包,
// 确保上游看到的是改写后的 Minecraft 主机名,而不是客户端原始值。
if pluginsManager != nil {
filter, err := pluginsManager.FilterHandshake(context.Background(), api.HandshakeFilterRequest{
SourceAddr: conn.RemoteAddr().String(),
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
})
if err != nil {
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("handshake filter failed")
return nil
}
if !filter.Allowed {
log.Info().Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Str("plugin", filter.PluginID).Str("reason", filter.Reason).Msg("handshake rejected by filter")
return nil
}
if filter.RewriteHost != "" && filter.RewriteHost != handshake.ServerHost {
initialData = protocol.ReplaceMcHost(initialData, filter.RewriteHost)
handshake = protocol.ParseHandshake(initialData)
}
}
func GetMcHost(buf []byte) string {
buf = buf[4:]
host_len := buf[0]
if len(buf)+1 < int(host_len) {
return ""
// 状态查询使用 NextState=1并且可以由插件直接完整响应。
// 如果这里已经处理,就不会再为该查询打开上游连接。
if handshake.NextState == 1 {
if handled := handleStatusPing(conn, handshake); handled {
return nil
}
}
return string(buf[1 : host_len+1])
routeResult := resolveGatewayRoute(conn, handshake)
host := routeResult.Decision.Upstream
ok := routeResult.Source != "fallback_miss"
if routeResult.Decision.Action == api.RouteDecisionReject || host == "" {
gatewayMetrics.RouteMiss()
log.Err(errEmptyBuffer).
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Str("route_source", routeResult.Source).
Str("route_action", routeResult.Decision.Action).
Msg("failed to route host")
return nil
}
if ok {
gatewayMetrics.RouteHit(handshake.ServerHost)
}
log.Debug().
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("map to host")
var client net.Conn
if pluginsManager != nil {
req := newUpstreamConnectRequest(conn, host, handshake, initialData, ok)
result, err := pluginsManager.ConnectUpstream(context.Background(), req)
if err != nil {
if errors.Is(err, api.ErrBlocked) {
log.Info().
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Msg("managed upstream plugin blocked connection")
return nil
}
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("failed to invoke managed upstream plugin")
return nil
}
if result.Handled {
if result.Proxied {
return nil
}
client = result.Conn
}
}
if client == nil {
ok, err = invokeFirstHookHandler(api.HookUpstream, Handler2[net.Conn, string, bool](conn, host), func(handler func(net.Conn, string) (net.Conn, error)) error {
var err error
client, err = handler(conn, host)
return err
})
if err != nil {
log.Err(err).Msg("Failed to invoke upstream hook")
return nil
}
}
if client == nil {
target := upstreamtarget.Parse(host)
// 路由值可以通过前缀选择非 TCP 传输;普通地址仍按 TCP 处理,
// 以保持旧配置的行为不变。
switch target.Protocol {
case upstreamtarget.ProtocolQUIC:
client = upstreamQuic(target.Address)
case upstreamtarget.ProtocolKCP:
client = upstreamKcp(target.Address)
case upstreamtarget.ProtocolHAProxy:
client = haProxyUpstream(conn, target.Address)
default:
client = upstreamTcp(target.Address)
}
}
if client == nil {
return nil
}
// 只有在上游路径确定后才回放握手数据。这样插件在任何上游字节发出前,
// 都还有机会阻断、代理或改写连接。
if err := writeAll(client, initialData); err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", handshake.ServerHost).
Str("mc", host).
Msg("failed to write initial packet to upstream")
client.Close()
return nil
}
return client
}
func resolveGatewayRoute(conn net.Conn, handshake protocol.Handshake) pluginmanager.RouteResolveResult {
upstream, hit := lookupRoute(handshake.ServerHost)
// SQLite 快照始终作为本地兜底。插件会同时拿到兜底决策和刷新回调,
// 因此可以选择性覆盖路由,而不必在插件里复制一套路由仓库逻辑。
req := api.RouteResolveRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
SourceAddr: conn.RemoteAddr().String(),
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
FallbackUpstream: upstream,
FallbackHit: hit,
Handshake: api.UpstreamHandshakeRef{
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
},
}
if pluginsManager != nil {
result, err := pluginsManager.ResolveRoute(context.Background(), req, func(req api.RouteResolveRequest) (string, bool) {
return lookupRoute(req.Host)
})
if err == nil {
return result
}
log.Err(err).Str("client", conn.RemoteAddr().String()).Str("host", handshake.ServerHost).Msg("route resolver failed")
}
action := api.RouteDecisionFallback
source := "sqlite_fallback"
if upstream == "" {
// 没有命中兜底路由时统一表示为拒绝决策,便于热路径记录一致的失败形态。
action = api.RouteDecisionReject
source = "fallback_miss"
}
return pluginmanager.RouteResolveResult{
Decision: api.RouteDecision{Action: action, Upstream: upstream, ProviderID: "sqlite", Reason: "sqlite route snapshot fallback"},
Source: source,
}
}
func handleStatusPing(conn net.Conn, handshake protocol.Handshake) bool {
if pluginsManager == nil {
return false
}
// Minecraft 状态响应是带长度前缀的 JSON 数据包。插件只提供高层字段,
// Minecraft 协议封包由 protocol.StatusResponsePacket 统一完成。
result, err := pluginsManager.StatusPing(context.Background(), api.StatusPingRequest{
Host: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
SourceAddr: conn.RemoteAddr().String(),
ProtocolVersion: handshake.ProtocolVersion,
})
if err != nil || !result.Handled {
if err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("status ping plugin failed")
}
return false
}
payload := map[string]any{
"description": map[string]any{"text": result.Response.MOTD},
"players": map[string]any{
"online": result.Response.OnlinePlayers,
"max": result.Response.MaxPlayers,
},
"version": map[string]any{
"name": result.Response.VersionText,
"protocol": result.Response.ProtocolVersion,
},
}
if result.Response.Favicon != "" {
payload["favicon"] = result.Response.Favicon
}
if result.Response.Maintenance {
payload["maintenance"] = map[string]any{"window": result.Response.MaintenanceWindow}
}
packet, err := protocol.StatusResponsePacket(payload)
if err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to build status response")
return true
}
if err := writeAll(conn, packet); err != nil {
log.Err(err).Str("host", handshake.ServerHost).Msg("failed to write status response")
}
return true
}
func newUpstreamConnectRequest(conn net.Conn, upstream string, handshake protocol.Handshake, initialData []byte, routeHit bool) api.UpstreamConnectRequest {
target := upstreamtarget.Parse(upstream)
transport, serviceName, listenerPort := connectionIngress(conn)
// InitialData 使用副本,避免上游插件在其他处理器或日志路径仍引用回放缓冲区时
// 意外修改调用方持有的数据。
req := api.UpstreamConnectRequest{
Source: conn,
Host: handshake.ServerHost,
Upstream: upstream,
InitialData: append([]byte(nil), initialData...),
Metadata: map[string]string{"route_hit": boolString(routeHit)},
ConnectionID: randomHexID(8),
TraceID: randomHexID(16),
SourceAddr: conn.RemoteAddr().String(),
ServerHost: handshake.ServerHost,
RawServerHost: handshake.RawServerHost,
ProtocolVersion: handshake.ProtocolVersion,
NextState: handshake.NextState,
RouteID: handshake.ServerHost,
RouteTags: []string{},
UpstreamRaw: upstream,
UpstreamProtocol: string(target.Protocol),
UpstreamAddress: target.Address,
Transport: transport,
ServiceName: serviceName,
ListenerPort: listenerPort,
}
return req
}
func connectionIngress(conn net.Conn) (transport string, serviceName string, listenerPort int) {
transport = "tcp"
serviceName = serviceNameTCPAdmin
// 具体连接包装类型记录了客户端来自哪个监听器。该元数据会传给插件,
// 并出现在运维诊断中,同时不需要改变 net.Conn 接口。
switch conn.(type) {
case *webSocketConn:
transport = "websocket"
serviceName = serviceNameWebSocket
case quicConn:
transport = "quic"
serviceName = serviceNameQUIC
}
if addr, ok := conn.LocalAddr().(*net.TCPAddr); ok {
listenerPort = addr.Port
}
return transport, serviceName, listenerPort
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func randomHexID(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return hex.EncodeToString([]byte("fallback"))
}
return hex.EncodeToString(buf)
}

414
cmd/gateway/main_test.go Normal file
View File

@@ -0,0 +1,414 @@
// cmd/gateway/main_test.go 包含用于约束 gateway 行为的测试。
package main
import (
"archive/zip"
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"github.com/tursom/mc-gateway/plugin/api"
)
func TestMapToHostUsesManagedPluginBeforeLegacyHook(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example")
source := newGatewayTestConn(packet)
managedUpstream := newGatewayTestConn(nil)
legacyUpstream := newGatewayTestConn(nil)
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
pluginsManager = pluginmanager.New(pluginmanager.Options{
DB: newGatewayTestPluginDB(t),
ArtifactRoot: t.TempDir(),
Adapter: gatewayTestPluginAdapter{handler: func(req api.UpstreamConnectRequest) (net.Conn, error) {
if req.Host != "play.example" || req.Upstream != "backend.example:25565" || !bytes.Equal(req.InitialData, packet) {
t.Fatalf("managed request = %+v, initial=%v", req, req.InitialData)
}
return managedUpstream, nil
}},
})
artifact := uploadGatewayTestArtifact(t, pluginsManager, "managed-upstream")
if _, err := pluginsManager.SetDesired(context.Background(), "admin", "managed-upstream", artifact.ID, pluginmanager.DesiredEnabled, `{}`, 10); err != nil {
t.Fatalf("SetDesired() error = %v", err)
}
if _, err := pluginsManager.Enable(context.Background(), "admin", "managed-upstream"); err != nil {
t.Fatalf("Enable() error = %v", err)
}
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return legacyUpstream, nil },
)
got := mapToHost(source)
if got != managedUpstream {
t.Fatalf("mapToHost() = %v, want managed upstream", got)
}
if !bytes.Equal(managedUpstream.writeBuf.Bytes(), packet) {
t.Fatalf("managed upstream initial packet = %v, want %v", managedUpstream.writeBuf.Bytes(), packet)
}
if legacyUpstream.writeBuf.Len() != 0 {
t.Fatalf("legacy upstream was used: %v", legacyUpstream.writeBuf.Bytes())
}
if _, err := pluginsManager.Disable(context.Background(), "admin", "managed-upstream"); err != nil {
t.Fatalf("Disable() error = %v", err)
}
nextSource := newGatewayTestConn(packet)
if got := mapToHost(nextSource); got != legacyUpstream {
t.Fatalf("mapToHost() after disable = %v, want legacy upstream", got)
}
}
func TestMapToHostRoutesThroughHookAndForwardsInitialPacket(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("play.example", 0x63, 0x00)
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn(nil)
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
var gotSource net.Conn
var gotHost string
registerGatewayUpstreamHook(
t,
func(source net.Conn, host string) bool {
gotSource = source
gotHost = host
return host == "backend.example:25565"
},
func(source net.Conn, host string) (net.Conn, error) {
return upstream, nil
},
)
got := mapToHost(source)
if got != upstream {
t.Fatalf("mapToHost() = %v, want upstream conn", got)
}
if gotSource != source {
t.Fatalf("hook source = %v, want original source", gotSource)
}
if gotHost != "backend.example:25565" {
t.Fatalf("hook host = %q, want backend.example:25565", gotHost)
}
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
}
}
func TestMapToHostUsesDefaultRoute(t *testing.T) {
defer saveGatewayState(t)()
packet := gatewayTestPacket("unknown.example")
source := newGatewayTestConn(packet)
upstream := newGatewayTestConn(nil)
setGatewayTestRoutes(map[string]string{
"default": "fallback.example:25565",
})
registerGatewayUpstreamHook(
t,
func(_ net.Conn, host string) bool {
return host == "fallback.example:25565"
},
func(net.Conn, string) (net.Conn, error) {
return upstream, nil
},
)
if got := mapToHost(source); got != upstream {
t.Fatalf("mapToHost() = %v, want fallback upstream", got)
}
if !bytes.Equal(upstream.writeBuf.Bytes(), packet) {
t.Fatalf("upstream initial packet = %v, want %v", upstream.writeBuf.Bytes(), packet)
}
}
func TestMapToHostRejectsInvalidOrUnroutedPackets(t *testing.T) {
tests := []struct {
name string
packet []byte
hosts map[string]string
}{
{
name: "read error",
packet: nil,
hosts: map[string]string{"default": "fallback.example:25565"},
},
{
name: "malformed packet",
packet: []byte{0x01, 0x02, 0x03, 0x04, 0x08, 'a'},
hosts: map[string]string{"default": "fallback.example:25565"},
},
{
name: "missing route",
packet: gatewayTestPacket("unknown.example"),
hosts: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(tt.packet)
if tt.packet == nil {
source.readErr = errors.New("read failed")
}
setGatewayTestRoutes(tt.hosts)
if got := mapToHost(source); got != nil {
t.Fatalf("mapToHost() = %v, want nil", got)
}
})
}
}
func TestMapToHostReturnsNilWhenHookFails(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(gatewayTestPacket("play.example"))
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
wantErr := errors.New("hook failed")
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) {
return nil, wantErr
},
)
if got := mapToHost(source); got != nil {
t.Fatalf("mapToHost() = %v, want nil", got)
}
}
func TestMapToHostClosesUpstreamWhenInitialWriteFails(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(gatewayTestPacket("play.example"))
upstream := newGatewayTestConn(nil)
upstream.writeErr = errors.New("write failed")
setGatewayTestRoutes(map[string]string{
"play.example": "backend.example:25565",
})
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) {
return upstream, nil
},
)
if got := mapToHost(source); got != nil {
t.Fatalf("mapToHost() = %v, want nil", got)
}
if !upstream.closed {
t.Fatal("upstream was not closed after write failure")
}
}
type gatewayTestPluginAdapter struct {
handler api.UpstreamConnectHandler
initHook func(*pluginmanager.Gateway) error
}
func (a gatewayTestPluginAdapter) Load(_ context.Context, _ pluginmanager.ArtifactRecord, _ pluginmanager.PluginRecord, gateway *pluginmanager.Gateway) (api.Plugin, error) {
handler := a.handler
if handler == nil {
handler = func(api.UpstreamConnectRequest) (net.Conn, error) {
return nil, api.ErrPass
}
}
if a.initHook == nil {
if err := api.RegisterHookHandler(
gateway,
api.HookUpstreamConnect,
func(api.UpstreamConnectRequest) bool { return true },
handler,
); err != nil {
return nil, err
}
} else if err := a.initHook(gateway); err != nil {
return nil, err
}
return &gatewayPluginStub{}, nil
}
func newGatewayTestPluginDB(t *testing.T) *sql.DB {
t.Helper()
db, err := admindb.Open(filepath.Join(t.TempDir(), "gateway.sqlite3"))
if err != nil {
t.Fatalf("Open() error = %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := admindb.Migrate(db); err != nil {
t.Fatalf("Migrate() error = %v", err)
}
return db
}
func uploadGatewayTestArtifact(t *testing.T, manager *pluginmanager.Manager, pluginID string) pluginmanager.ArtifactRecord {
t.Helper()
return uploadGatewayTestArtifactWithCapabilities(t, manager, pluginID, "")
}
func uploadGatewayTestArtifactWithCapabilities(t *testing.T, manager *pluginmanager.Manager, pluginID string, capabilities string) pluginmanager.ArtifactRecord {
t.Helper()
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGPWithCapabilities(t, pluginID, capabilities),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func uploadGatewayTestArtifactWithManifest(t *testing.T, manager *pluginmanager.Manager, pluginID string, mutate func(*pluginmanager.Manifest)) pluginmanager.ArtifactRecord {
t.Helper()
var manifest pluginmanager.Manifest
if err := json.Unmarshal(gatewayTestManifest(t, pluginID), &manifest); err != nil {
t.Fatalf("Unmarshal manifest error = %v", err)
}
if mutate != nil {
mutate(&manifest)
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
artifact, err := manager.UploadArtifact(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: writeGatewayTestMCGPEntries(t, map[string][]byte{
"manifest.json": data,
"plugin.so": []byte("fake plugin bytes " + pluginID),
}),
FileName: pluginID + ".mcgp",
Actor: "admin",
})
if err != nil {
t.Fatalf("UploadArtifact() error = %v", err)
}
return artifact
}
func approveGatewayPluginGovernanceForTest(t *testing.T, pluginID, artifactID string) {
t.Helper()
if _, err := pluginsManager.CreateReview(context.Background(), "admin", pluginID, pluginmanager.GovernanceReviewRequest{
ArtifactID: artifactID,
Profile: pluginmanager.PolicyProfileProd,
Decision: pluginmanager.ReviewDecisionApproved,
Notes: "test approval",
}); err != nil {
t.Fatalf("CreateReview(%s) error = %v", pluginID, err)
}
}
func writeGatewayTestMCGP(t *testing.T, pluginID string) string {
return writeGatewayTestMCGPWithCapabilities(t, pluginID, "")
}
func writeGatewayTestMCGPWithCapabilities(t *testing.T, pluginID string, capabilities string) string {
t.Helper()
entries := map[string][]byte{
"manifest.json": gatewayTestManifestWithCapabilities(t, pluginID, capabilities),
"plugin.so": []byte("fake plugin bytes " + pluginID),
}
return writeGatewayTestMCGPEntries(t, entries)
}
func writeGatewayTestMCGPEntries(t *testing.T, entries map[string][]byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "plugin.mcgp")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create zip error = %v", err)
}
writer := zip.NewWriter(file)
for name, data := range entries {
entry, err := writer.Create(name)
if err != nil {
t.Fatalf("Create entry error = %v", err)
}
if _, err := entry.Write(data); err != nil {
t.Fatalf("Write entry error = %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("Close zip writer error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close zip file error = %v", err)
}
return path
}
func gatewayTestManifest(t *testing.T, pluginID string) []byte {
return gatewayTestManifestWithCapabilities(t, pluginID, "")
}
func gatewayProtocolProxyCapabilities() string {
return `{
"upstream_connect":{"mode":"protocol-proxy"},
"scope":{"type":"host","values":["play.example"]},
"rollout":{"mode":"canary"},
"minecraft":{
"protocol_versions":{"tested":[767]},
"forwarding":{"supported":["none"],"default":"none"}
}
}`
}
func gatewayTestManifestWithCapabilities(t *testing.T, pluginID string, capabilities string) []byte {
t.Helper()
if capabilities == "" {
capabilities = `{"extension_points":["upstream.connect/v1"]}`
}
manifest := pluginmanager.Manifest{
SchemaVersion: pluginmanager.SchemaVersion,
ID: pluginID,
Name: "Managed Upstream",
Version: "0.1.0",
ArtifactType: pluginmanager.ArtifactTypeBinary,
Runtime: pluginmanager.RuntimeManifest{
Type: pluginmanager.RuntimeGoPlugin,
Entry: pluginmanager.RuntimeEntry,
EntrySymbol: "Plugin",
},
APIVersion: pluginmanager.APIVersion,
GoVersion: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
ExtensionPoints: []pluginmanager.ExtensionPoint{{
Type: "hook",
Key: pluginmanager.ExtensionUpstreamConnect,
}},
Capabilities: json.RawMessage(capabilities),
}
data, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("Marshal manifest error = %v", err)
}
return data
}

33
cmd/gateway/pid.go Normal file
View File

@@ -0,0 +1,33 @@
// cmd/gateway/pid.go 维护 pid 文件的写入和清理,供进程管理器按文件追踪网关进程。
package main
import (
"fmt"
"os"
)
var currentPidFile string
func writePIDFile() error {
newPidFile := getPidFileFromConfig()
if newPidFile == "" || newPidFile == currentPidFile {
return nil
}
if currentPidFile != "" {
removePIDFile()
}
currentPidFile = newPidFile
pid := os.Getpid()
if err := os.WriteFile(newPidFile, []byte(fmt.Sprintf("%d\n", pid)), 0644); err != nil {
return err
}
return nil
}
func removePIDFile() {
os.Remove(currentPidFile)
}

14
cmd/gateway/pid_unix.go Normal file
View File

@@ -0,0 +1,14 @@
// cmd/gateway/pid_unix.go 实现 Unix 平台的 pid 文件占用检查,避免覆盖仍在运行的进程记录。
// pid_unix.go
//go:build unix || plan9
package main
func getPidFileFromConfig() string {
pidFile := config.PidFile
if pidFile == "" {
return "/dev/shm/mc-gateway.pid"
}
return pidFile
}

View File

@@ -0,0 +1,15 @@
//go:build unix || plan9
// cmd/gateway/pid_unix_test.go 包含用于约束 pid unix 行为的测试。
package main
import "testing"
func TestGetPidFileFromConfigDefaultOnUnix(t *testing.T) {
defer saveGatewayState(t)()
if got := getPidFileFromConfig(); got != "/dev/shm/mc-gateway.pid" {
t.Fatalf("getPidFileFromConfig() = %q, want /dev/shm/mc-gateway.pid", got)
}
}

View File

@@ -0,0 +1,10 @@
// cmd/gateway/pid_windows.go 在 Windows 上提供可移植的 pid 文件占用检查替代实现。
// pid_windows.go
//go:build windows
package main
func getPidFileFromConfig() string {
return config.PidFile
}

255
cmd/gateway/plugin.go Normal file
View File

@@ -0,0 +1,255 @@
// cmd/gateway/plugin.go 把网关运行时接入 pluginmanager负责钩子分发和插件生命周期加载。
package main
import (
"context"
"errors"
"net"
"plugin"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/plugin/api"
)
var (
exitWaitGroup sync.WaitGroup
pluginLock sync.RWMutex
plugins = make(map[string]api.Plugin)
hooks = make(map[string]map[string]any)
)
type (
Gateway struct {
pluginId string
}
)
func loadPlugins() {
pluginLock.Lock()
defer pluginLock.Unlock()
for pluginKey, pluginConfig := range config.Plugin {
if enable, ok := pluginConfig["enable"].(bool); ok && enable {
pluginFile := pluginKey
if file, ok := pluginConfig["file"].(string); ok {
pluginFile = file
}
if _, ok := plugins[pluginKey]; ok {
continue
}
gateway := &Gateway{
pluginId: pluginKey,
}
log.Info().Str("plugin", pluginKey).Msg("Loading plugin")
p, err := plugin.Open(pluginFile + ".so")
if err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to open plugin")
continue
}
pluginSymbol, err := p.Lookup("Plugin")
if err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to lookup plugin")
continue
}
pluginFactory, ok := pluginSymbol.(func() api.Plugin)
if !ok {
log.Err(err).Str("plugin", pluginKey).Msg("Invalid plugin factory signature")
continue
}
pluginInstance := pluginFactory()
cfgObj := pluginInstance.NewConfigObj()
if err := loadPluginConfig(pluginConfig, cfgObj); err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to load plugin config")
continue
}
if err := pluginInstance.ReloadConfig(cfgObj); err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to reload plugin config")
continue
}
if err := pluginInstance.Init(gateway); err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to initialize plugin")
continue
}
plugins[pluginKey] = pluginInstance
hooks[pluginKey] = make(map[string]any)
log.Info().Str("plugin", pluginKey).Msg("Plugin loaded successfully")
} else {
if plugin, ok := plugins[pluginKey]; ok {
if err := plugin.Destroy(); err != nil {
log.Err(err).Str("plugin", pluginKey).Msg("Failed to destroy plugin")
}
log.Info().Str("plugin", pluginKey).Msg("Plugin disabled")
}
delete(plugins, pluginKey)
delete(hooks, pluginKey)
}
}
}
// HandleConn 实现 api.Gateway用于让插件把连接交回网关主流程。
func (g *Gateway) HandleConn(conn net.Conn) {
go handleRequest(conn)
}
// Hook 实现 api.Gateway用于注册旧版内存钩子处理器。
func (g *Gateway) Hook(hook string, handler any) error {
pluginLock.Lock()
defer pluginLock.Unlock()
hooks[g.pluginId][hook] = handler
return nil
}
// ExitWaitGroup 实现 api.Gateway用于把插件后台任务纳入进程退出等待。
func (g *Gateway) ExitWaitGroup() *sync.WaitGroup {
return &exitWaitGroup
}
func (g *Gateway) EmitEvent(ctx context.Context, name string, fields map[string]string) error {
_ = ctx
_ = name
_ = fields
return nil
}
func (g *Gateway) ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error {
_ = ctx
_ = name
_ = value
_ = labels
return nil
}
func (g *Gateway) Logger() api.Logger {
return noopPluginLogger{}
}
func (g *Gateway) DataStore() api.DataStore {
return noopPluginDataStore{}
}
func (g *Gateway) FileStore() api.FileStore {
return noopPluginFileStore{}
}
func (g *Gateway) ExternalClient(name string) api.ExternalClient {
_ = name
return noopExternalClient{}
}
func (g *Gateway) RegisterBackgroundTask(task api.BackgroundTask) error {
_ = task
return nil
}
// TestOp 实现 api.Gateway保留给测试或调试插件能力探测。
func (g *Gateway) TestOp() {
panic("unimplemented")
}
type noopPluginLogger struct{}
func (noopPluginLogger) Debug(context.Context, string, map[string]string) {}
func (noopPluginLogger) Info(context.Context, string, map[string]string) {}
func (noopPluginLogger) Warn(context.Context, string, map[string]string) {}
func (noopPluginLogger) Error(context.Context, string, map[string]string) {}
type noopPluginDataStore struct{}
func (noopPluginDataStore) Put(context.Context, api.DataRecord) error { return nil }
func (noopPluginDataStore) Get(context.Context, string) (api.DataRecord, error) {
return api.DataRecord{}, errors.New("plugin data store is unavailable")
}
func (noopPluginDataStore) Delete(context.Context, string) error { return nil }
type noopPluginFileStore struct{}
func (noopPluginFileStore) ResourcePath(string) (string, error) {
return "", errors.New("plugin file store is unavailable")
}
func (noopPluginFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
return nil
}
func (noopPluginFileStore) Read(context.Context, string, string, int64) ([]byte, error) {
return nil, errors.New("plugin file store is unavailable")
}
func (noopPluginFileStore) Delete(context.Context, string, string) error { return nil }
type noopExternalClient struct{}
func (noopExternalClient) DoHTTP(context.Context, api.ExternalRequest) (api.ExternalResponse, error) {
return api.ExternalResponse{}, errors.New("external client is unavailable")
}
func (noopExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
return nil, errors.New("external client is unavailable")
}
func (noopExternalClient) HealthCheck(context.Context) error { return nil }
func Handler1[T1, R any](t1 T1) func(func(T1) R) R {
return func(acceptor func(T1) R) R {
return acceptor(t1)
}
}
func Handler2[T1, T2, R any](t1 T1, t2 T2) func(func(T1, T2) R) R {
return func(acceptor func(T1, T2) R) R {
return acceptor(t1, t2)
}
}
func Handler1R2[T1, R1, R2 any](t1 T1) func(func(T1) (R1, R2)) (R1, R2) {
return func(acceptor func(T1) (R1, R2)) (R1, R2) {
return acceptor(t1)
}
}
func invokeFirstHookHandler[Acceptor, Handler any](
hook api.HookType[Acceptor, Handler],
acceptor func(Acceptor) bool,
handelr func(Handler) error,
) (bool, error) {
pluginLock.RLock()
defer pluginLock.RUnlock()
for _, handlers := range hooks {
if handler, ok := handlers[hook.Key()].(api.HookHandler[Acceptor, Handler]); ok && acceptor(handler.Acceptor()) {
if err := handelr(handler.Handler()); err != nil {
return true, err
}
return true, nil
}
}
return false, nil
}
func invokeAllHookHandler[Acceptor, Handler any](
hook api.HookType[Acceptor, Handler],
acceptor func(Acceptor) bool,
handelr func(Handler) error,
) error {
pluginLock.RLock()
defer pluginLock.RUnlock()
for _, handlers := range hooks {
if handler, ok := handlers[hook.Key()].(api.HookHandler[Acceptor, Handler]); ok && acceptor(handler.Acceptor()) {
if err := handelr(handler.Handler()); err != nil {
return err
}
}
}
return nil
}

400
cmd/gateway/plugin_cli.go Normal file
View File

@@ -0,0 +1,400 @@
// cmd/gateway/plugin_cli.go 分发插件相关子命令,包括本地脚手架、构建、清单和远程管理操作。
package main
import (
"archive/zip"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"github.com/tursom/mc-gateway/internal/admindb"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
func runPluginCLI(args []string) (bool, int) {
if len(args) < 1 || args[0] != "plugin" {
return false, 0
}
if len(args) < 2 {
printPluginCLIUsage()
return true, 2
}
command := args[1]
switch command {
case "init":
if err := runPluginInitCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "features":
if err := runPluginFeaturesCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "manifest":
if err := runPluginManifestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "preflight":
if err := runPluginPreflightCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "self-test":
if err := runPluginSelfTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "benchmark":
if err := runPluginBenchmarkCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "status":
if err := runPluginRemoteStatusCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "upload":
if err := runPluginRemoteUploadCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "enable":
if err := runPluginRemoteDesiredCLI(args[2:], pluginmanager.DesiredEnabled); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "disable":
if err := runPluginRemoteActionCLI(args[2:], "disable"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "delete":
if err := runPluginRemoteActionCLI(args[2:], "delete"); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "rollback":
if err := runPluginRemoteRollbackCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "config":
if err := runPluginRemoteConfigCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "secret":
if err := runPluginRemoteSecretCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "logs", "events", "metrics":
if err := runPluginRemoteOperationsSectionCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "diagnose":
if err := runPluginRemoteDiagnoseCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "task":
if err := runPluginRemoteTaskCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "data", "files":
if err := runPluginRemoteResourceCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "gc":
if err := runPluginRemoteGCCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "review":
if err := runPluginRemoteReviewCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "advisory":
if err := runPluginRemoteAdvisoryCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "repo":
if err := runPluginRemoteRepoCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "sbom", "verify":
if err := runPluginRemoteSupplyChainCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "runtime":
if err := runPluginRuntimeCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "schema", "contract", "conformance", "export", "import", "diff", "drift", "dr-drill", "sign":
if err := runPluginReservedCLI(command, args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "build":
if err := runPluginBuildCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "test":
if err := runPluginTestCLI(args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "inspect":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
manifest, err := readPackageManifest(packagePath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(manifest); err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
return true, 0
case "validate", "compat":
artifact, err := runPluginValidatePathCLI(args[2:], "")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
fmt.Fprintf(os.Stdout, "ok plugin=%s version=%s sha256=%s api=%s go=%s %s/%s\n",
artifact.PluginID, artifact.Version, artifact.SHA256, artifact.APIVersion, artifact.GoVersion, artifact.GOOS, artifact.GOARCH)
return true, 0
case "source-validate":
source, err := runPluginValidatePathCLI(args[2:], pluginmanager.ArtifactTypeSource)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
fmt.Fprintf(os.Stdout, "ok source plugin=%s version=%s source_sha256=%s api=%s go=%s %s/%s\n",
source.PluginID, source.Version, source.SHA256, source.APIVersion, source.GoVersion, source.GOOS, source.GOARCH)
return true, 0
case "source-build":
if len(args) < 3 {
printPluginCLIUsage()
return true, 2
}
packagePath := args[2]
outPath := ""
if len(args) >= 4 {
outPath = args[3]
}
build, out, err := buildSourcePackageForCLI(packagePath, outPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return true, 1
}
fmt.Fprintf(os.Stdout, "ok build=%d plugin=%s source_sha256=%s artifact_sha256=%s builder=%s go=%s status=%s out=%s\n",
build.ID, build.PluginID, build.SourceSHA256, build.ArtifactSHA256, build.BuilderType, build.GoVersion, build.Status, out)
return true, 0
default:
fmt.Fprintf(os.Stderr, "unknown plugin command %q\n", command)
return true, 2
}
}
func printPluginCLIUsage() {
fmt.Fprintln(os.Stderr, "usage: gateway plugin init|features|manifest|build|test|preflight|self-test|benchmark|status|upload|enable|disable|delete|rollback|config|secret|logs|events|metrics|diagnose|task|data|files|gc|review|advisory|repo|sbom|verify|runtime|inspect|validate|compat|source-validate|source-build ...")
}
func buildSourcePackageForCLI(packagePath, outPath string) (pluginmanager.BuildRecord, string, error) {
tmpRoot, err := os.MkdirTemp("", "mcgp-source-build-cli-*")
if err != nil {
return pluginmanager.BuildRecord{}, "", err
}
defer os.RemoveAll(tmpRoot)
db, err := openPluginCLIDB(filepath.Join(tmpRoot, "plugins.db"))
if err != nil {
return pluginmanager.BuildRecord{}, "", err
}
defer db.Close()
manager := pluginmanager.New(pluginmanager.Options{
DB: db,
ArtifactRoot: filepath.Join(tmpRoot, "artifacts"),
})
source, err := manager.UploadSource(context.Background(), pluginmanager.ArtifactUpload{
SourcePath: packagePath,
FileName: filepath.Base(packagePath),
Actor: "cli",
})
if err != nil {
return pluginmanager.BuildRecord{}, "", err
}
builds, err := manager.ListBuilds(context.Background(), source.PluginID)
if err != nil {
return pluginmanager.BuildRecord{}, "", err
}
var queued pluginmanager.BuildRecord
for _, build := range builds {
if build.SourceID == source.ID && build.Status == pluginmanager.BuildStatusQueued {
queued = build
break
}
}
if queued.ID == 0 {
return pluginmanager.BuildRecord{}, "", fmt.Errorf("source upload did not create a queued build")
}
build, err := manager.RunBuild(context.Background(), "cli", queued.ID)
if err != nil {
return pluginmanager.BuildRecord{}, "", err
}
if build.Status != pluginmanager.BuildStatusSucceeded {
if build.LogSummary != "" {
return build, "", fmt.Errorf("source build status %s: %s\n%s", build.Status, build.Error, build.LogSummary)
}
return build, "", fmt.Errorf("source build status %s: %s", build.Status, build.Error)
}
artifact, err := manager.Artifact(context.Background(), build.ArtifactID)
if err != nil {
return build, "", err
}
if outPath == "" {
outPath = filepath.Join(filepath.Dir(packagePath), artifact.PluginID+"-built.mcgp")
}
if err := packageBinaryArtifact(artifact, outPath); err != nil {
return build, "", err
}
return build, outPath, nil
}
func packageBinaryArtifact(artifact pluginmanager.ArtifactRecord, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
for _, entry := range []struct {
name string
path string
}{
{name: "manifest.json", path: filepath.Join(filepath.Dir(artifact.FilePath), "manifest.json")},
{name: pluginmanager.RuntimeEntry, path: artifact.FilePath},
} {
if err := addZipFile(zw, entry.name, entry.path); err != nil {
zw.Close()
return err
}
}
if err := zw.Close(); err != nil {
return err
}
return out.Close()
}
func addZipFile(zw *zip.Writer, name, path string) error {
writer, err := zw.Create(name)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
}
func openPluginCLIDB(path string) (*sql.DB, error) {
db, err := admindb.Open(path)
if err != nil {
return nil, err
}
if err := admindb.Migrate(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func readPackageManifest(packagePath string) (pluginmanager.Manifest, error) {
reader, err := zip.OpenReader(packagePath)
if err != nil {
return pluginmanager.Manifest{}, err
}
defer reader.Close()
for _, file := range reader.File {
if file.Name != "manifest.json" {
continue
}
rc, err := file.Open()
if err != nil {
return pluginmanager.Manifest{}, err
}
defer rc.Close()
data, err := io.ReadAll(io.LimitReader(rc, pluginmanager.DefaultManifestMaxBytes+1))
if err != nil {
return pluginmanager.Manifest{}, err
}
if len(data) > pluginmanager.DefaultManifestMaxBytes {
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json exceeds %d bytes", pluginmanager.DefaultManifestMaxBytes)
}
var manifest pluginmanager.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return pluginmanager.Manifest{}, err
}
return manifest, nil
}
return pluginmanager.Manifest{}, fmt.Errorf("manifest.json is required")
}

View File

@@ -0,0 +1,348 @@
// cmd/gateway/plugin_cli_manifest.go 实现插件包清单的查看、校验、特性列表和格式化命令。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/tursom/mc-gateway/internal/pluginmanager"
"gopkg.in/yaml.v3"
)
const (
manifestFormatJSON = "json"
manifestFormatJSONC = "jsonc"
manifestFormatYAML = "yaml"
manifestFormatTOML = "toml"
)
var manifestSourceNames = []string{
"manifest.yaml",
"manifest.yml",
"manifest.toml",
"manifest.jsonc",
"manifest.json",
}
type pluginManifestSource struct {
Path string
Format string
Data []byte
Manifest pluginmanager.Manifest
Raw map[string]any
CanonicalJSON []byte
}
func readPluginDirManifest(dir, explicitManifestPath string) (pluginmanager.Manifest, map[string]any, error) {
source, err := readPluginManifestSource(dir, explicitManifestPath)
if err != nil {
return pluginmanager.Manifest{}, nil, err
}
return source.Manifest, source.Raw, nil
}
func readPluginManifestSource(target, explicitManifestPath string) (pluginManifestSource, error) {
manifestPath, err := resolveManifestSourcePath(target, explicitManifestPath)
if err != nil {
return pluginManifestSource{}, err
}
return readPluginManifestSourceFile(manifestPath)
}
func resolveManifestSourcePath(target, explicitManifestPath string) (string, error) {
if explicitManifestPath != "" {
return resolveExplicitManifestPath(target, explicitManifestPath)
}
info, err := os.Stat(target)
if err != nil {
return "", err
}
if !info.IsDir() {
if !isManifestSourceFile(target) {
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), target)
}
return target, nil
}
var found []string
for _, name := range manifestSourceNames {
candidate := filepath.Join(target, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
found = append(found, candidate)
}
}
if len(found) == 0 {
return "", fmt.Errorf("plugin manifest is required: expected one of %s in %s", strings.Join(manifestSourceNames, ", "), target)
}
if len(found) > 1 {
sort.Strings(found)
return "", fmt.Errorf("multiple plugin manifests found: %s; pass --manifest to select one", strings.Join(found, ", "))
}
return found[0], nil
}
func resolveExplicitManifestPath(target, explicitManifestPath string) (string, error) {
candidates := []string{explicitManifestPath}
if info, err := os.Stat(target); err == nil && info.IsDir() && !filepath.IsAbs(explicitManifestPath) {
candidates = []string{filepath.Join(target, explicitManifestPath), explicitManifestPath}
}
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err != nil {
continue
}
if info.IsDir() {
return "", fmt.Errorf("manifest path %q is a directory", candidate)
}
if !isManifestSourceFile(candidate) {
return "", fmt.Errorf("manifest path must be one of %s, got %q", strings.Join(manifestSourceNames, ", "), candidate)
}
return candidate, nil
}
return "", fmt.Errorf("manifest path %q is not readable", explicitManifestPath)
}
func readPluginManifestSourceFile(manifestPath string) (pluginManifestSource, error) {
format, err := manifestFormatForPath(manifestPath)
if err != nil {
return pluginManifestSource{}, err
}
data, err := os.ReadFile(manifestPath)
if err != nil {
return pluginManifestSource{}, err
}
raw, err := decodeManifestSource(data, format)
if err != nil {
return pluginManifestSource{}, fmt.Errorf("invalid %s: %w", filepath.Base(manifestPath), err)
}
canonical, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return pluginManifestSource{}, err
}
canonical = append(canonical, '\n')
var manifest pluginmanager.Manifest
if err := json.Unmarshal(canonical, &manifest); err != nil {
return pluginManifestSource{}, fmt.Errorf("invalid %s object: %w", filepath.Base(manifestPath), err)
}
if manifest.ID == "" {
return pluginManifestSource{}, errors.New("manifest id is required")
}
return pluginManifestSource{
Path: manifestPath,
Format: format,
Data: data,
Manifest: manifest,
Raw: raw,
CanonicalJSON: canonical,
}, nil
}
func decodeManifestSource(data []byte, format string) (map[string]any, error) {
var raw any
switch format {
case manifestFormatJSON:
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
case manifestFormatJSONC:
stripped, err := stripJSONC(data)
if err != nil {
return nil, err
}
if err := json.Unmarshal(stripped, &raw); err != nil {
return nil, err
}
case manifestFormatYAML:
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
case manifestFormatTOML:
var table map[string]any
if err := toml.Unmarshal(data, &table); err != nil {
return nil, err
}
raw = table
default:
return nil, fmt.Errorf("unsupported manifest format %q", format)
}
normalized, ok := normalizeManifestValue(raw).(map[string]any)
if !ok {
return nil, errors.New("manifest root must be an object")
}
return normalized, nil
}
func normalizeManifestValue(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[key] = normalizeManifestValue(item)
}
return out
case map[any]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[fmt.Sprint(key)] = normalizeManifestValue(item)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = normalizeManifestValue(item)
}
return out
default:
return value
}
}
func manifestFormatForPath(filePath string) (string, error) {
switch strings.ToLower(filepath.Base(filePath)) {
case "manifest.json":
return manifestFormatJSON, nil
case "manifest.jsonc":
return manifestFormatJSONC, nil
case "manifest.yaml", "manifest.yml":
return manifestFormatYAML, nil
case "manifest.toml":
return manifestFormatTOML, nil
default:
return "", fmt.Errorf("unsupported manifest file %q", filePath)
}
}
func isManifestSourceFile(filePath string) bool {
_, err := manifestFormatForPath(filePath)
return err == nil
}
func stripJSONC(data []byte) ([]byte, error) {
withoutComments, err := stripJSONCComments(data)
if err != nil {
return nil, err
}
return stripJSONCTrailingCommas(withoutComments), nil
}
func stripJSONCComments(data []byte) ([]byte, error) {
out := make([]byte, 0, len(data))
inString := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out = append(out, ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == '/' && i+1 < len(data) {
next := data[i+1]
if next == '/' {
out = append(out, ' ', ' ')
i += 2
for ; i < len(data); i++ {
if data[i] == '\n' || data[i] == '\r' {
out = append(out, data[i])
break
}
out = append(out, ' ')
}
continue
}
if next == '*' {
out = append(out, ' ', ' ')
i += 2
closed := false
for ; i < len(data); i++ {
if data[i] == '*' && i+1 < len(data) && data[i+1] == '/' {
out = append(out, ' ', ' ')
i++
closed = true
break
}
if data[i] == '\n' || data[i] == '\r' {
out = append(out, data[i])
} else {
out = append(out, ' ')
}
}
if !closed {
return nil, errors.New("unterminated block comment")
}
continue
}
}
out = append(out, ch)
}
if inString {
return nil, errors.New("unterminated string")
}
return out, nil
}
func stripJSONCTrailingCommas(data []byte) []byte {
var out bytes.Buffer
inString := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out.WriteByte(ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out.WriteByte(ch)
continue
}
if ch == ',' {
j := i + 1
for j < len(data) && isJSONWhitespace(data[j]) {
j++
}
if j < len(data) && (data[j] == '}' || data[j] == ']') {
continue
}
}
out.WriteByte(ch)
}
return out.Bytes()
}
func isJSONWhitespace(ch byte) bool {
return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'
}

View File

@@ -0,0 +1,979 @@
// cmd/gateway/plugin_cli_remote.go 实现通过 Admin API 驱动插件管理操作的命令行客户端。
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/tursom/mc-gateway/internal/pluginmanager"
)
type pluginRemoteOptions struct {
Gateway string
Token string
Target string
Extra []string
ArtifactID string
ConfigPath string
ConfigJSON string
Priority int
Source bool
SnapshotID int64
FullDesired bool
Profile string
Action string
Decision string
Notes string
Reason string
TTLSeconds int64
ConfirmToken string
DryRun bool
RepositoryType string
IndexPath string
Version string
TrustPolicy string
MetadataPath string
MetadataJSON string
BenchmarkProfile string
P95MS float64
P99MS float64
ErrorRate float64
ActiveProxyCapacity int64
BaselineDiff float64
Mode string
}
type pluginRemoteClient struct {
baseURL string
token string
client *http.Client
}
func runPluginRemoteStatusCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugins"
if opts.Target != "" {
endpoint = "/plugins/" + url.PathEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
}
func runPluginRemoteUploadCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("upload requires an artifact path")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-artifacts"
if opts.Source {
endpoint = "/plugin-sources"
}
return client.uploadArtifact(endpoint, opts.Target)
}
func runPluginRemoteDesiredCLI(args []string, desiredState string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("enable requires a plugin id")
}
if opts.ArtifactID == "" {
return errors.New("enable requires --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"desired_state": desiredState,
"config_json": configJSON,
"priority": opts.Priority,
"source": "cli",
"requested_mode": "desired",
}
return client.doToStdout(http.MethodPut, "/plugins/"+url.PathEscape(opts.Target), body)
}
func runPluginRemoteActionCLI(args []string, action string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", action)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/"+action, nil)
}
func runPluginRemoteRollbackCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("rollback requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
if opts.SnapshotID > 0 {
body := map[string]any{"snapshot_id": opts.SnapshotID, "full_desired": opts.FullDesired}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/config", body)
}
if opts.ArtifactID == "" {
return errors.New("rollback requires --artifact or --snapshot")
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/rollback/artifact", map[string]any{"artifact_id": opts.ArtifactID})
}
func runPluginRemoteConfigCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin config validate <plugin-id> ...")
}
switch args[0] {
case "validate":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("config validate requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
configJSON, err := remoteConfigJSON(opts)
if err != nil {
return err
}
body := map[string]any{"artifact_id": opts.ArtifactID, "config_json": configJSON}
if opts.Priority != 0 {
body["priority"] = opts.Priority
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/config/dry-run", body)
default:
return fmt.Errorf("unknown config command %q", args[0])
}
}
func runPluginRemoteSecretCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin secret check <plugin-id> ...")
}
switch args[0] {
case "check":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("secret check requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/secrets", nil)
default:
return fmt.Errorf("unknown secret command %q", args[0])
}
}
func runPluginRemoteOperationsSectionCLI(command string, args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
result := map[string]any{"plugin_id": opts.Target}
switch command {
case "logs":
result["logs"] = operations["logs"]
result["traces"] = operations["traces"]
case "events":
result["events"] = operations["events"]
result["event_queue"] = operations["event_queue"]
case "metrics":
result["handlers"] = operations["handlers"]
result["custom_metrics"] = operations["custom_metrics"]
default:
return fmt.Errorf("unknown operations section %q", command)
}
return encodePluginCLIJSON(result)
}
func runPluginRemoteDiagnoseCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("diagnose requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/diagnostics", nil)
}
func runPluginRemoteTaskCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin task list|run|cancel ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("task list requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
"background_tasks": operations["background_tasks"],
})
case "run":
opts, err := parsePluginRemoteOptionsWithPositionals(args[1:], 2)
if err != nil {
return err
}
if opts.Target == "" || len(opts.Extra) == 0 {
return errors.New("task run requires a plugin id and task id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
taskID := opts.Extra[0]
body := map[string]any{"confirm_token": opts.ConfirmToken}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/operations/tasks/"+url.PathEscape(taskID)+"/trigger", body)
case "cancel":
return runPluginReservedCLI("task cancel", args[1:])
default:
return fmt.Errorf("unknown task command %q", args[0])
}
}
func runPluginRemoteResourceCLI(command string, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: gateway plugin %s inspect|gc ...", command)
}
switch args[0] {
case "inspect":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s inspect requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := client.doJSON(http.MethodGet, "/plugins/"+url.PathEscape(opts.Target)+"/operations", nil)
if err != nil {
return err
}
operations, _ := body["operations"].(map[string]any)
field := "plugin_data"
if command == "files" {
field = "plugin_files"
}
return encodePluginCLIJSON(map[string]any{
"plugin_id": opts.Target,
field: operations[field],
"gc": operations["gc"],
})
case "gc":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("%s gc requires a plugin id", command)
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
return client.doToStdout(method, "/plugins/"+url.PathEscape(opts.Target)+"/operations/gc", nil)
case "export":
return runPluginReservedCLI(command+" export", args[1:])
default:
return fmt.Errorf("unknown %s command %q", command, args[0])
}
}
func runPluginRemoteGCCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
method := http.MethodGet
if !opts.DryRun {
method = http.MethodPost
}
endpoint := "/plugin-gc"
if opts.Target != "" {
endpoint = "/plugin-operations-gc?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(method, endpoint, nil)
}
func runPluginRemoteReviewCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin review status|approve|reject|override ...")
}
switch args[0] {
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return errors.New("review status requires a plugin id")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
values := url.Values{}
if opts.ArtifactID != "" {
values.Set("artifact_id", opts.ArtifactID)
}
if opts.Profile != "" {
values.Set("profile", opts.Profile)
}
endpoint := "/plugins/" + url.PathEscape(opts.Target) + "/governance"
if query := values.Encode(); query != "" {
endpoint += "?" + query
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "approve", "reject":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" {
return fmt.Errorf("review %s requires a plugin id", args[0])
}
if opts.ArtifactID == "" {
return fmt.Errorf("review %s requires --artifact", args[0])
}
decision := pluginmanager.ReviewDecisionApproved
if args[0] == "reject" {
decision = pluginmanager.ReviewDecisionRejected
}
if opts.Decision != "" {
decision = opts.Decision
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"decision": decision,
"notes": opts.Notes,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/review", body)
case "override":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("review override requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"artifact_id": opts.ArtifactID,
"profile": opts.Profile,
"action": opts.Action,
"reason": opts.Reason,
"ttl_seconds": opts.TTLSeconds,
}
return client.doToStdout(http.MethodPost, "/plugins/"+url.PathEscape(opts.Target)+"/governance/override", body)
default:
return fmt.Errorf("unknown review command %q", args[0])
}
}
func runPluginRemoteAdvisoryCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin advisory scan|import ...")
}
switch args[0] {
case "scan":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
endpoint := "/plugin-advisories"
if opts.Target != "" {
endpoint += "?plugin_id=" + url.QueryEscape(opts.Target)
}
return client.doToStdout(http.MethodGet, endpoint, nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-advisories", body)
default:
return fmt.Errorf("unknown advisory command %q", args[0])
}
}
func runPluginRemoteRepoCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin repo list|import|search|show ...")
}
switch args[0] {
case "list":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-repositories/imports", nil)
case "import":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
body := map[string]any{
"repository_type": opts.RepositoryType,
"index_path": opts.IndexPath,
"artifact_id": opts.ArtifactID,
"plugin_id": opts.Target,
"version": opts.Version,
"trust_policy": opts.TrustPolicy,
}
return client.doToStdout(http.MethodPost, "/plugin-repositories/imports", body)
case "search", "show":
return runPluginReservedCLI("repo "+args[0], args[1:])
default:
return fmt.Errorf("unknown repo command %q", args[0])
}
}
func runPluginRemoteSupplyChainCLI(command string, args []string) error {
if command == "sbom" {
if len(args) == 0 {
return errors.New("usage: gateway plugin sbom verify|generate ...")
}
switch args[0] {
case "verify":
return runPluginRemoteSupplyChainAssessCLI(args[1:])
case "generate":
return runPluginReservedCLI("sbom generate", args[1:])
default:
return fmt.Errorf("unknown sbom command %q", args[0])
}
}
return runPluginRemoteSupplyChainAssessCLI(args)
}
func runPluginRemoteSupplyChainAssessCLI(args []string) error {
opts, err := parsePluginRemoteOptions(args)
if err != nil {
return err
}
if opts.Target == "" || opts.ArtifactID == "" {
return errors.New("supply-chain verification requires a plugin id and --artifact")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
metadata, err := remoteMetadataJSON(opts)
if err != nil {
return err
}
body := map[string]any{
"plugin_id": opts.Target,
"artifact_id": opts.ArtifactID,
"metadata": metadata,
}
return client.doToStdout(http.MethodPost, "/plugin-supply-chain", body)
}
func runPluginRuntimeCLI(args []string) error {
if len(args) == 0 {
return errors.New("usage: gateway plugin runtime features|status|mode|apply ...")
}
switch args[0] {
case "features":
return runPluginFeaturesCLI(nil)
case "status":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodGet, "/plugin-service", nil)
case "mode":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
if opts.Mode == "" {
return errors.New("runtime mode requires --mode")
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPut, "/plugin-service", map[string]any{"desired_mode": opts.Mode})
case "apply":
opts, err := parsePluginRemoteOptions(args[1:])
if err != nil {
return err
}
client, err := newPluginRemoteClient(opts)
if err != nil {
return err
}
return client.doToStdout(http.MethodPost, "/plugin-service", nil)
default:
return fmt.Errorf("unknown runtime command %q", args[0])
}
}
func runPluginReservedCLI(command string, args []string) error {
_ = args
return encodePluginCLIJSON(map[string]any{
"command": command,
"status": "reserved",
"message": "command is reserved by the plugin toolchain design but is not implemented in this gateway yet",
})
}
func parsePluginRemoteOptions(args []string) (pluginRemoteOptions, error) {
return parsePluginRemoteOptionsWithPositionals(args, 1)
}
func parsePluginRemoteOptionsWithPositionals(args []string, maxPositionals int) (pluginRemoteOptions, error) {
opts := pluginRemoteOptions{
Gateway: os.Getenv("MC_GATEWAY_ADMIN_URL"),
Token: os.Getenv("MC_GATEWAY_ADMIN_TOKEN"),
Priority: pluginmanager.DefaultPriority,
DryRun: true,
}
var positionals []string
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
if len(positionals) >= maxPositionals {
return pluginRemoteOptions{}, fmt.Errorf("unexpected argument %q", arg)
}
positionals = append(positionals, arg)
continue
}
key, value, consumed, err := parsePluginCLIFlag(args, i)
if err != nil {
return pluginRemoteOptions{}, err
}
i += consumed
switch key {
case "gateway":
opts.Gateway = value
case "token":
opts.Token = value
case "artifact":
opts.ArtifactID = value
case "config":
opts.ConfigPath = value
case "config-json":
opts.ConfigJSON = value
case "priority":
parsed, err := strconv.Atoi(value)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --priority %q: %w", value, err)
}
opts.Priority = parsed
case "source":
opts.Source = parsePluginBoolFlag(value)
case "snapshot":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --snapshot %q: %w", value, err)
}
opts.SnapshotID = parsed
case "full-desired":
opts.FullDesired = parsePluginBoolFlag(value)
case "profile":
opts.Profile = value
case "action":
opts.Action = value
case "decision":
opts.Decision = value
case "notes":
opts.Notes = value
case "reason":
opts.Reason = value
case "ttl":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --ttl %q: %w", value, err)
}
opts.TTLSeconds = parsed
case "confirm-token":
opts.ConfirmToken = value
case "dry-run":
opts.DryRun = parsePluginBoolFlag(value)
case "repository-type":
opts.RepositoryType = value
case "index":
opts.IndexPath = value
case "version":
opts.Version = value
case "trust-policy":
opts.TrustPolicy = value
case "metadata":
opts.MetadataPath = value
case "metadata-json":
opts.MetadataJSON = value
case "benchmark-profile":
opts.BenchmarkProfile = value
case "p95-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p95-ms %q: %w", value, err)
}
opts.P95MS = parsed
case "p99-ms":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --p99-ms %q: %w", value, err)
}
opts.P99MS = parsed
case "error-rate":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --error-rate %q: %w", value, err)
}
opts.ErrorRate = parsed
case "active-proxy-capacity":
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --active-proxy-capacity %q: %w", value, err)
}
opts.ActiveProxyCapacity = parsed
case "baseline-diff":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return pluginRemoteOptions{}, fmt.Errorf("invalid --baseline-diff %q: %w", value, err)
}
opts.BaselineDiff = parsed
case "mode":
opts.Mode = value
default:
return pluginRemoteOptions{}, fmt.Errorf("unknown remote flag --%s", key)
}
}
if len(positionals) > 0 {
opts.Target = positionals[0]
}
if len(positionals) > 1 {
opts.Extra = append(opts.Extra, positionals[1:]...)
}
return opts, nil
}
func newPluginRemoteClient(opts pluginRemoteOptions) (pluginRemoteClient, error) {
if strings.TrimSpace(opts.Gateway) == "" {
return pluginRemoteClient{}, errors.New("--gateway or MC_GATEWAY_ADMIN_URL is required")
}
if strings.TrimSpace(opts.Token) == "" {
return pluginRemoteClient{}, errors.New("--token or MC_GATEWAY_ADMIN_TOKEN is required")
}
base, err := normalizeAdminAPIBase(opts.Gateway)
if err != nil {
return pluginRemoteClient{}, err
}
return pluginRemoteClient{baseURL: base, token: opts.Token, client: http.DefaultClient}, nil
}
func normalizeAdminAPIBase(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", err
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway URL %q", raw)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = strings.TrimRight(parsed.Path, "/")
switch {
case parsed.Path == "":
parsed.Path = "/admin/api"
case strings.HasSuffix(parsed.Path, "/admin/api"):
case strings.HasSuffix(parsed.Path, "/admin"):
parsed.Path = parsed.Path + "/api"
default:
parsed.Path = path.Join(parsed.Path, "admin/api")
}
return parsed.String(), nil
}
func (c pluginRemoteClient) doToStdout(method, endpoint string, body any) error {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func (c pluginRemoteClient) doJSON(method, endpoint string, body any) (map[string]any, error) {
data, err := c.doBytes(method, endpoint, body)
if err != nil {
return nil, err
}
if len(data) == 0 {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("admin API response is not a JSON object: %w", err)
}
return decoded, nil
}
func (c pluginRemoteClient) doBytes(method, endpoint string, body any) ([]byte, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, c.baseURL+endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return readPluginRemoteResponse(resp)
}
func (c pluginRemoteClient) uploadArtifact(endpoint, filePath string) error {
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile("artifact", filepath.Base(filePath))
if err != nil {
return err
}
file, err := os.Open(filePath)
if err != nil {
return err
}
if _, err := io.Copy(part, file); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, c.baseURL+endpoint, &payload)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := readPluginRemoteResponse(resp)
if err != nil {
return err
}
return writePluginRemoteData(data)
}
func readPluginRemoteResponse(resp *http.Response) ([]byte, error) {
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(data))
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("admin API %s: %s", resp.Status, message)
}
return data, nil
}
func writePluginRemoteData(data []byte) error {
if len(data) == 0 {
fmt.Fprintln(os.Stdout, "{}")
return nil
}
var pretty bytes.Buffer
if json.Indent(&pretty, data, "", " ") == nil {
pretty.WriteByte('\n')
_, err := pretty.WriteTo(os.Stdout)
return err
}
_, err := os.Stdout.Write(data)
if err == nil && len(data) > 0 && data[len(data)-1] != '\n' {
fmt.Fprintln(os.Stdout)
}
return err
}
func remoteConfigJSON(opts pluginRemoteOptions) (string, error) {
if opts.ConfigJSON != "" {
if !json.Valid([]byte(opts.ConfigJSON)) {
return "", errors.New("--config-json must be valid JSON")
}
return opts.ConfigJSON, nil
}
if opts.ConfigPath != "" {
data, err := os.ReadFile(opts.ConfigPath)
if err != nil {
return "", err
}
if !json.Valid(data) {
return "", fmt.Errorf("config file %q must contain valid JSON", opts.ConfigPath)
}
return string(data), nil
}
return "{}", nil
}
func remoteMetadataJSON(opts pluginRemoteOptions) (map[string]any, error) {
if opts.MetadataJSON != "" {
var decoded map[string]any
if err := json.Unmarshal([]byte(opts.MetadataJSON), &decoded); err != nil {
return nil, fmt.Errorf("--metadata-json must be a JSON object: %w", err)
}
return decoded, nil
}
if opts.MetadataPath != "" {
data, err := os.ReadFile(opts.MetadataPath)
if err != nil {
return nil, err
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("metadata file %q must contain a JSON object: %w", opts.MetadataPath, err)
}
return decoded, nil
}
return map[string]any{}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
// cmd/gateway/plugin_cli_toolchain_test.go 包含用于约束 plugin cli toolchain 行为的测试。
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPluginInitCreatesBuildableTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sample-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "sample-plugin",
"--module", "example.com/sample-plugin",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(init) code = %d, want 0", code)
}
for _, name := range []string{"manifest.yaml", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json"} {
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil {
t.Fatalf("generated file %s stat error = %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil {
t.Fatal("plugin init generated manifest.json by default, want manifest.yaml")
}
if _, err := validatePluginDirectoryForCLI(dir, ""); err != nil {
t.Fatalf("validatePluginDirectoryForCLI() error = %v", err)
}
}
func TestPluginBuildSourcePackagesTemplate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "source-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "source-plugin",
"--module", "example.com/source-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
out := filepath.Join(t.TempDir(), "source-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "source",
"--out", out,
"--skip-tests",
"--vendor=false",
})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(build source) code = %d, want 0", code)
}
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
assertZipContains(t, out, "manifest.json", "go.mod", "main.go", "main_test.go", "README.md", "testdata/config.json")
assertZipNotContains(t, out, "manifest.yaml")
}
func TestPluginBuildBothAcceptsOutDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "both-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "both-plugin",
"--module", "example.com/both-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
outDir := filepath.Join(t.TempDir(), "packages")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "both",
"--out", outDir,
"--skip-tests",
"--vendor=false",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build both) = (%v, %d), want handled code 0", handled, code)
}
binaryOut := filepath.Join(outDir, "both-plugin.mcgp")
sourceOut := filepath.Join(outDir, "both-plugin-source.mcgp")
if _, err := validatePluginPathForCLI(binaryOut, "binary"); err != nil {
t.Fatalf("validatePluginPathForCLI(binary) error = %v", err)
}
if _, err := validatePluginPathForCLI(sourceOut, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
assertZipNotContains(t, binaryOut, "manifest.yaml")
assertZipNotContains(t, sourceOut, "manifest.yaml")
}
func TestPluginTestManifestProfile(t *testing.T) {
dir := filepath.Join(t.TempDir(), "test-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "test-plugin",
"--module", "example.com/test-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(test manifest) code = %d, want 0", code)
}
}
func TestPluginFeaturesAndManifestCommands(t *testing.T) {
handled, code := runPluginCLI([]string{"plugin", "features"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(features) code = %d, want 0", code)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "explain", "upstream.connect/v1"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest explain) code = %d, want 0", code)
}
}
func TestPluginManifestFormatWrite(t *testing.T) {
dir := filepath.Join(t.TempDir(), "format-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "format-plugin",
"--module", "example.com/format-plugin",
"--manifest-format", "json",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
manifestPath := filepath.Join(dir, "manifest.json")
if err := os.WriteFile(manifestPath, []byte(`{"schema_version":"mc-gateway.plugin/v1","id":"format-plugin","name":"Format Plugin","version":"0.1.0","artifact_type":"source","runtime":{"type":"go-plugin","entry":"plugin.so","entry_symbol":"Plugin"},"build":{"type":"go","entry":".","output":"plugin.so"},"api_version":"plugin-api/v1","sdk_module":"github.com/tursom/mc-gateway/plugin/api","sdk_module_version":"v0.1.0","extension_points":[{"type":"hook","key":"upstream.connect/v1"}],"capabilities":{"upstream_connect":{"mode":"dialer"}},"runtime_limits":{"handler_timeout_ms":3000},"config_schema":{"type":"object"}}`), 0644); err != nil {
t.Fatalf("WriteFile(manifest) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
if !handled {
t.Fatal("runPluginCLI() handled = false")
}
if code != 0 {
t.Fatalf("runPluginCLI(manifest format) code = %d, want 0", code)
}
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("ReadFile(manifest) error = %v", err)
}
if !strings.Contains(string(data), "\n \"schema_version\"") {
t.Fatalf("manifest was not formatted:\n%s", data)
}
}
func TestPluginManifestSourceFormats(t *testing.T) {
for _, format := range []string{"yaml", "toml", "jsonc", "json"} {
t.Run(format, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "format-"+format)
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "format-" + format,
"--module", "example.com/format-" + format,
"--manifest-format", format,
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init %s) = (%v, %d), want handled code 0", format, handled, code)
}
source, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(%s) error = %v", format, err)
}
if source.Manifest.ID != "format-"+format {
t.Fatalf("manifest id = %q, want format-%s", source.Manifest.ID, format)
}
if !json.Valid(source.CanonicalJSON) {
t.Fatalf("canonical JSON for %s is invalid:\n%s", format, source.CanonicalJSON)
}
packaged, err := materializedManifestJSON(source.Raw, source.Manifest, "binary", false)
if err != nil {
t.Fatalf("materializedManifestJSON(%s) error = %v", format, err)
}
var raw map[string]any
if err := json.Unmarshal(packaged, &raw); err != nil {
t.Fatalf("Unmarshal(materialized %s) error = %v", format, err)
}
if raw["artifact_type"] != "binary" || raw["go_version"] == "" || raw["go_os"] == "" || raw["go_arch"] == "" {
t.Fatalf("materialized %s manifest missing package fields: %s", format, packaged)
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(validate %s) = (%v, %d), want handled code 0", format, handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(test %s) = (%v, %d), want handled code 0", format, handled, code)
}
})
}
}
func TestPluginManifestFormatWritePreservesComments(t *testing.T) {
for _, tc := range []struct {
name string
file string
content string
comment string
}{
{
name: "yaml",
file: "manifest.yaml",
content: "# keep yaml comment\n" + manifestYAMLTemplate(pluginInitCLIOptions{ID: "comment-yaml", Name: "Comment YAML", Extension: "upstream.connect/v1"}),
comment: "# keep yaml comment",
},
{
name: "toml",
file: "manifest.toml",
content: "# keep toml comment\n" + manifestTOMLTemplate(pluginInitCLIOptions{ID: "comment-toml", Name: "Comment TOML", Extension: "upstream.connect/v1"}),
comment: "# keep toml comment",
},
{
name: "jsonc",
file: "manifest.jsonc",
content: strings.Replace(
"// keep jsonc comment\n"+strings.TrimSuffix(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "comment-jsonc", Name: "Comment JSONC", Extension: "upstream.connect/v1"}, true), "\n"),
"\n }\n}",
"\n },\n}",
1,
),
comment: "// keep jsonc comment",
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, tc.file)
if err := os.WriteFile(manifestPath, []byte(tc.content), 0644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", tc.file, err)
}
before, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(before) error = %v", err)
}
handled, code := runPluginCLI([]string{"plugin", "manifest", "format", dir, "--write"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(manifest format %s) = (%v, %d), want handled code 0", tc.name, handled, code)
}
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", tc.file, err)
}
if !strings.Contains(string(data), tc.comment) {
t.Fatalf("formatted %s lost comment:\n%s", tc.file, data)
}
after, err := readPluginManifestSource(dir, "")
if err != nil {
t.Fatalf("readPluginManifestSource(after) error = %v", err)
}
if !bytes.Equal(before.CanonicalJSON, after.CanonicalJSON) {
t.Fatalf("canonical JSON changed after format\nbefore=%s\nafter=%s", before.CanonicalJSON, after.CanonicalJSON)
}
})
}
}
func TestPluginManifestMultipleSourcesRequireExplicitManifest(t *testing.T) {
dir := filepath.Join(t.TempDir(), "multi-manifest")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "multi-manifest",
"--module", "example.com/multi-manifest",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestSourceJSONTemplate(pluginInitCLIOptions{ID: "multi-manifest", Name: "Multi Manifest", Extension: "upstream.connect/v1"}, false)), 0644); err != nil {
t.Fatalf("WriteFile(manifest.json) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir})
if !handled {
t.Fatal("runPluginCLI(validate) handled = false")
}
if code == 0 {
t.Fatal("runPluginCLI(validate) code = 0, want failure for multiple manifests")
}
handled, code = runPluginCLI([]string{"plugin", "validate", dir, "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(validate --manifest) = (%v, %d), want handled code 0", handled, code)
}
handled, code = runPluginCLI([]string{"plugin", "test", dir, "--profile", "manifest", "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(test --manifest) = (%v, %d), want handled code 0", handled, code)
}
out := filepath.Join(t.TempDir(), "multi-manifest-source.mcgp")
handled, code = runPluginCLI([]string{"plugin", "build", dir, "--type", "source", "--out", out, "--skip-tests", "--vendor=false", "--manifest", "manifest.yaml"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build --manifest) = (%v, %d), want handled code 0", handled, code)
}
if _, err := validatePluginPathForCLI(out, "source"); err != nil {
t.Fatalf("validatePluginPathForCLI(source) error = %v", err)
}
handled, code = runPluginCLI([]string{"plugin", "manifest", "format", dir, "--manifest", "manifest.yaml", "--canonical-json", "--type", "source"})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(manifest format --manifest --canonical-json) = (%v, %d), want handled code 0", handled, code)
}
}
func TestPluginGovernanceCommands(t *testing.T) {
dir := filepath.Join(t.TempDir(), "governance-plugin")
handled, code := runPluginCLI([]string{
"plugin", "init", dir,
"--id", "governance-plugin",
"--module", "example.com/governance-plugin",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(init) = (%v, %d), want handled code 0", handled, code)
}
artifact := filepath.Join(t.TempDir(), "governance-plugin.mcgp")
handled, code = runPluginCLI([]string{
"plugin", "build", dir,
"--type", "binary",
"--out", artifact,
"--skip-tests",
})
if !handled || code != 0 {
t.Fatalf("runPluginCLI(build binary) = (%v, %d), want handled code 0", handled, code)
}
for _, tc := range [][]string{
{"plugin", "preflight", artifact, "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
{"plugin", "self-test", artifact, "--profile", "dev"},
{"plugin", "benchmark", artifact, "--profile", "dev", "--benchmark-profile", "local-fast", "--p95-ms", "1", "--p99-ms", "2", "--error-rate", "0", "--baseline-diff", "0.1"},
{"plugin", "preflight", dir, "--manifest", "manifest.yaml", "--config-json", `{"upstream":"127.0.0.1:25566"}`, "--profile", "dev"},
} {
handled, code = runPluginCLI(tc)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", tc)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", tc, code)
}
}
}
func TestPluginRemoteCLIRequests(t *testing.T) {
type observedRequest struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}
requests := make(chan observedRequest, 16)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q, want bearer token", got)
}
observed := observedRequest{
Method: r.Method,
RequestURI: r.URL.RequestURI(),
ContentType: r.Header.Get("Content-Type"),
Body: map[string]any{},
}
switch {
case strings.HasPrefix(observed.ContentType, "application/json"):
if err := json.NewDecoder(r.Body).Decode(&observed.Body); err != nil {
t.Errorf("Decode JSON body error = %v", err)
}
case strings.HasPrefix(observed.ContentType, "multipart/form-data"):
if err := r.ParseMultipartForm(64 << 20); err != nil {
t.Errorf("ParseMultipartForm error = %v", err)
} else {
file, header, err := r.FormFile("artifact")
if err != nil {
t.Errorf("FormFile(artifact) error = %v", err)
} else {
observed.FileName = header.Filename
_, _ = io.Copy(io.Discard, file)
_ = file.Close()
}
}
}
requests <- observed
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/operations") {
_, _ = io.WriteString(w, `{"operations":{"logs":[{"message":"ok"}],"traces":[],"events":[{"name":"evt"}],"event_queue":{"queued":1},"handlers":[{"plugin_id":"demo"}],"custom_metrics":[],"background_tasks":[{"id":"sync"}],"plugin_data":[{"key":"k"}],"plugin_files":[{"name":"f"}],"gc":[]}}`)
return
}
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer server.Close()
t.Setenv("MC_GATEWAY_ADMIN_URL", server.URL)
t.Setenv("MC_GATEWAY_ADMIN_TOKEN", "test-token")
runRemotePluginCLI(t, "plugin", "status", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo")
artifactPath := filepath.Join(t.TempDir(), "demo.mcgp")
if err := os.WriteFile(artifactPath, []byte("artifact"), 0644); err != nil {
t.Fatalf("WriteFile(artifact) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "upload", artifactPath)
uploadReq := <-requests
assertRemoteRequest(t, uploadReq, http.MethodPost, "/admin/api/plugin-artifacts")
if uploadReq.FileName != "demo.mcgp" {
t.Fatalf("upload file name = %q, want demo.mcgp", uploadReq.FileName)
}
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(configPath, []byte(`{"upstream":"127.0.0.1:25565"}`), 0644); err != nil {
t.Fatalf("WriteFile(config) error = %v", err)
}
runRemotePluginCLI(t, "plugin", "enable", "demo", "--artifact", "art-1", "--config", configPath, "--priority", "7")
enableReq := <-requests
assertRemoteRequest(t, enableReq, http.MethodPut, "/admin/api/plugins/demo")
if enableReq.Body["artifact_id"] != "art-1" || enableReq.Body["desired_state"] != "enabled" || enableReq.Body["priority"].(float64) != 7 {
t.Fatalf("enable body = %#v", enableReq.Body)
}
runRemotePluginCLI(t, "plugin", "logs", "demo")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/operations")
runRemotePluginCLI(t, "plugin", "task", "run", "demo", "sync", "--confirm-token", "confirm")
taskReq := <-requests
assertRemoteRequest(t, taskReq, http.MethodPost, "/admin/api/plugins/demo/operations/tasks/sync/trigger")
if taskReq.Body["confirm_token"] != "confirm" {
t.Fatalf("task body = %#v", taskReq.Body)
}
runRemotePluginCLI(t, "plugin", "repo", "import", "demo", "--repository-type", "file", "--index", "repo.json", "--artifact", "candidate-1", "--version", "1.2.3")
repoReq := <-requests
assertRemoteRequest(t, repoReq, http.MethodPost, "/admin/api/plugin-repositories/imports")
if repoReq.Body["plugin_id"] != "demo" || repoReq.Body["repository_type"] != "file" || repoReq.Body["artifact_id"] != "candidate-1" {
t.Fatalf("repo body = %#v", repoReq.Body)
}
runRemotePluginCLI(t, "plugin", "review", "status", "demo", "--artifact", "art-1", "--profile", "prod")
assertRemoteRequest(t, <-requests, http.MethodGet, "/admin/api/plugins/demo/governance?artifact_id=art-1&profile=prod")
runRemotePluginCLI(t, "plugin", "sbom", "verify", "demo", "--artifact", "art-1", "--metadata-json", `{"sbom":{"format":"spdx"}}`)
supplyReq := <-requests
assertRemoteRequest(t, supplyReq, http.MethodPost, "/admin/api/plugin-supply-chain")
if supplyReq.Body["plugin_id"] != "demo" || supplyReq.Body["artifact_id"] != "art-1" {
t.Fatalf("supply-chain body = %#v", supplyReq.Body)
}
runRemotePluginCLI(t, "plugin", "runtime", "mode", "--mode", "go-plugin-process")
runtimeReq := <-requests
assertRemoteRequest(t, runtimeReq, http.MethodPut, "/admin/api/plugin-service")
if runtimeReq.Body["desired_mode"] != "go-plugin-process" {
t.Fatalf("runtime body = %#v", runtimeReq.Body)
}
}
func TestNormalizeAdminAPIBase(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin", want: "http://127.0.0.1:8080/admin/api"},
{raw: "http://127.0.0.1:8080/admin/api/", want: "http://127.0.0.1:8080/admin/api"},
} {
got, err := normalizeAdminAPIBase(tc.raw)
if err != nil {
t.Fatalf("normalizeAdminAPIBase(%q) error = %v", tc.raw, err)
}
if got != tc.want {
t.Fatalf("normalizeAdminAPIBase(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func runRemotePluginCLI(t *testing.T, args ...string) {
t.Helper()
handled, code := runPluginCLI(args)
if !handled {
t.Fatalf("runPluginCLI(%v) handled = false", args)
}
if code != 0 {
t.Fatalf("runPluginCLI(%v) code = %d, want 0", args, code)
}
}
func assertRemoteRequest(t *testing.T, got struct {
Method string
RequestURI string
ContentType string
Body map[string]any
FileName string
}, wantMethod, wantURI string) {
t.Helper()
if got.Method != wantMethod || got.RequestURI != wantURI {
t.Fatalf("request = %s %s, want %s %s", got.Method, got.RequestURI, wantMethod, wantURI)
}
}
func assertZipContains(t *testing.T, zipPath string, names ...string) {
t.Helper()
reader, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
}
defer reader.Close()
seen := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
seen[file.Name] = true
if strings.Contains(file.Name, `\`) {
t.Fatalf("zip entry %q uses backslash", file.Name)
}
}
for _, name := range names {
if !seen[name] {
t.Fatalf("zip %s missing entry %s; entries=%v", zipPath, name, seen)
}
}
}
func assertZipNotContains(t *testing.T, zipPath string, names ...string) {
t.Helper()
reader, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatalf("OpenReader(%s) error = %v", zipPath, err)
}
defer reader.Close()
seen := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
seen[file.Name] = true
}
for _, name := range names {
if seen[name] {
t.Fatalf("zip %s unexpectedly contains entry %s", zipPath, name)
}
}
}

284
cmd/gateway/plugin_test.go Normal file
View File

@@ -0,0 +1,284 @@
// cmd/gateway/plugin_test.go 包含用于约束 plugin 行为的测试。
package main
import (
"errors"
"net"
"testing"
"github.com/tursom/mc-gateway/plugin/api"
)
func TestGatewayHookStoresHandler(t *testing.T) {
defer saveGatewayState(t)()
pluginLock.Lock()
hooks["plugin-a"] = make(map[string]any)
pluginLock.Unlock()
handler := "handler"
if err := (&Gateway{pluginId: "plugin-a"}).Hook("hook-a", handler); err != nil {
t.Fatalf("Hook() error = %v", err)
}
if got := hooks["plugin-a"]["hook-a"]; got != handler {
t.Fatalf("stored hook = %v, want %v", got, handler)
}
}
func TestHandlerAdaptors(t *testing.T) {
if got := Handler1[int, bool](7)(func(v int) bool { return v == 7 }); !got {
t.Fatal("Handler1 did not pass its argument")
}
if got := Handler2[int, string, bool](7, "x")(func(v int, s string) bool {
return v == 7 && s == "x"
}); !got {
t.Fatal("Handler2 did not pass its arguments")
}
gotString, gotBool := Handler1R2[int, string, bool](7)(func(v int) (string, bool) {
return "ok", v == 7
})
if gotString != "ok" || !gotBool {
t.Fatalf("Handler1R2() = (%q, %v), want (ok, true)", gotString, gotBool)
}
}
func TestInvokeFirstHookHandler(t *testing.T) {
defer saveGatewayState(t)()
source := newGatewayTestConn(nil)
upstream := newGatewayTestConn(nil)
registerGatewayUpstreamHook(
t,
func(gotSource net.Conn, host string) bool {
return gotSource == source && host == "backend.example:25565"
},
func(net.Conn, string) (net.Conn, error) {
return upstream, nil
},
)
var handled bool
ok, err := invokeFirstHookHandler(
api.HookUpstream,
Handler2[net.Conn, string, bool](source, "backend.example:25565"),
func(handler func(net.Conn, string) (net.Conn, error)) error {
got, err := handler(source, "backend.example:25565")
if err != nil {
return err
}
handled = got == upstream
return nil
},
)
if err != nil {
t.Fatalf("invokeFirstHookHandler() error = %v", err)
}
if !ok {
t.Fatal("invokeFirstHookHandler() ok = false, want true")
}
if !handled {
t.Fatal("first matching handler was not invoked")
}
}
func TestInvokeFirstHookHandlerNoMatch(t *testing.T) {
defer saveGatewayState(t)()
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return false },
func(net.Conn, string) (net.Conn, error) {
t.Fatal("handler should not be invoked")
return nil, nil
},
)
ok, err := invokeFirstHookHandler(
api.HookUpstream,
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
func(func(net.Conn, string) (net.Conn, error)) error {
t.Fatal("callback should not be invoked")
return nil
},
)
if err != nil {
t.Fatalf("invokeFirstHookHandler() error = %v", err)
}
if ok {
t.Fatal("invokeFirstHookHandler() ok = true, want false")
}
}
func TestInvokeFirstHookHandlerReturnsCallbackError(t *testing.T) {
defer saveGatewayState(t)()
wantErr := errors.New("callback failed")
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return nil, nil },
)
ok, err := invokeFirstHookHandler(
api.HookUpstream,
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
func(func(net.Conn, string) (net.Conn, error)) error {
return wantErr
},
)
if !ok {
t.Fatal("invokeFirstHookHandler() ok = false, want true")
}
if !errors.Is(err, wantErr) {
t.Fatalf("invokeFirstHookHandler() error = %v, want %v", err, wantErr)
}
}
func TestInvokeAllHookHandler(t *testing.T) {
defer saveGatewayState(t)()
for _, pluginID := range []string{"plugin-a", "plugin-b"} {
pluginLock.Lock()
hooks[pluginID] = make(map[string]any)
pluginLock.Unlock()
if err := api.RegisterHookHandler(
&Gateway{pluginId: pluginID},
api.HookUpstream,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return nil, nil },
); err != nil {
t.Fatalf("RegisterHookHandler(%s) error = %v", pluginID, err)
}
}
calls := 0
err := invokeAllHookHandler(
api.HookUpstream,
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
func(func(net.Conn, string) (net.Conn, error)) error {
calls++
return nil
},
)
if err != nil {
t.Fatalf("invokeAllHookHandler() error = %v", err)
}
if calls != 2 {
t.Fatalf("invokeAllHookHandler() calls = %d, want 2", calls)
}
}
func TestInvokeAllHookHandlerReturnsCallbackError(t *testing.T) {
defer saveGatewayState(t)()
wantErr := errors.New("callback failed")
registerGatewayUpstreamHook(
t,
func(net.Conn, string) bool { return true },
func(net.Conn, string) (net.Conn, error) { return nil, nil },
)
err := invokeAllHookHandler(
api.HookUpstream,
Handler2[net.Conn, string, bool](newGatewayTestConn(nil), "backend.example:25565"),
func(func(net.Conn, string) (net.Conn, error)) error {
return wantErr
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("invokeAllHookHandler() error = %v, want %v", err, wantErr)
}
}
func TestLoadPluginsDisablesExistingPlugin(t *testing.T) {
defer saveGatewayState(t)()
plugin := &gatewayPluginStub{}
plugins["plugin-a"] = plugin
hooks["plugin-a"] = map[string]any{"hook": "handler"}
config.Plugin = map[string]map[string]any{
"plugin-a": {"enable": false},
}
loadPlugins()
if plugin.destroyCalls != 1 {
t.Fatalf("Destroy() calls = %d, want 1", plugin.destroyCalls)
}
if _, ok := plugins["plugin-a"]; ok {
t.Fatal("disabled plugin was not removed")
}
if _, ok := hooks["plugin-a"]; ok {
t.Fatal("disabled plugin hooks were not removed")
}
}
func TestLoadPluginsSkipsAlreadyLoadedEnabledPlugin(t *testing.T) {
defer saveGatewayState(t)()
plugin := &gatewayPluginStub{}
plugins["plugin-a"] = plugin
hooks["plugin-a"] = make(map[string]any)
config.Plugin = map[string]map[string]any{
"plugin-a": {"enable": true, "file": "missing-plugin-file"},
}
loadPlugins()
if plugins["plugin-a"] != plugin {
t.Fatal("existing enabled plugin was replaced")
}
if plugin.destroyCalls != 0 {
t.Fatalf("Destroy() calls = %d, want 0", plugin.destroyCalls)
}
}
func TestLoadPluginsIgnoresMissingPluginFile(t *testing.T) {
defer saveGatewayState(t)()
config.Plugin = map[string]map[string]any{
"plugin-a": {"enable": true, "file": "missing-plugin-file"},
}
loadPlugins()
if _, ok := plugins["plugin-a"]; ok {
t.Fatal("missing plugin file should not be registered")
}
if _, ok := hooks["plugin-a"]; ok {
t.Fatal("missing plugin file should not create hooks")
}
}
type gatewayPluginStub struct {
destroyCalls int
}
func (p *gatewayPluginStub) Init(api.Gateway) error {
return nil
}
func (p *gatewayPluginStub) Destroy() error {
p.destroyCalls++
return nil
}
func (p *gatewayPluginStub) NewConfigObj() any {
return &struct{}{}
}
func (p *gatewayPluginStub) ReloadConfig(any) error {
return nil
}
func TestGatewayExitWaitGroup(t *testing.T) {
if got := (&Gateway{}).ExitWaitGroup(); got != &exitWaitGroup {
t.Fatalf("ExitWaitGroup() = %p, want %p", got, &exitWaitGroup)
}
}
func TestGatewayPluginStubSatisfiesInterface(t *testing.T) {
var _ api.Plugin = (*gatewayPluginStub)(nil)
var _ api.Gateway = (*Gateway)(nil)
}

193
cmd/gateway/quic.go Normal file
View File

@@ -0,0 +1,193 @@
// cmd/gateway/quic.go 启动可选的 QUIC 监听器,并把 QUIC 流适配到普通网关连接流程。
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"sync"
"time"
quic "github.com/quic-go/quic-go"
"github.com/rs/zerolog/log"
)
type (
// quicConn 把 QUIC connection 和单条 stream 组合成 net.Conn 风格对象,
// 使后续转发逻辑不用区分 TCP 与 QUIC。
quicConn struct {
quic.Connection
quic.Stream
}
)
func runQuic(wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
// QUIC 基于 UDP 监听,端口来自运行态服务配置。
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: config.Quic.Port})
if err != nil {
log.Panic().Err(err).Msg("Failed to listen UDP")
}
defer udpConn.Close()
tlsConf, err := generateTLSConfig()
if err != nil {
log.Panic().Err(err).Msg("Failed to generate TLS config")
}
// quic-go 的 listener 接收 connection真正的字节流在 stream 中。
ln, err := quic.Listen(udpConn, tlsConf, nil)
if err != nil {
log.Panic().Err(err).Msg("Failed to listen QUIC")
}
log.Info().Int("port", config.Quic.Port).Msg("Listening for QUIC connections")
for {
conn, err := ln.Accept(context.Background())
if err != nil {
log.Err(err).Msg("Error accepting QUIC connection")
continue
}
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("Accepted QUIC connection")
go handleQuicRequest(conn)
}
}
func upstreamQuic(host string) net.Conn {
tlsConf := &tls.Config{
// 网关自管的 QUIC 上游默认使用临时证书,当前先跳过证书校验。
InsecureSkipVerify: true, // 跳过证书检查
NextProtos: getQuicNextProtos(),
}
// 上游握手使用短超时,避免连接协程在不可达上游上长期等待。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := quic.DialAddr(ctx, host, tlsConf, nil)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Failed to dial QUIC")
return nil
}
stream, err := conn.OpenStream()
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Failed to open stream")
conn.CloseWithError(0, "failed to open stream")
return nil
}
log.Debug().Str("host", host).Msg("QUIC stream opened")
// 返回的 quicConn 后续会收到 Minecraft 首包回放并进入普通双向转发。
return quicConn{
Connection: conn,
Stream: stream,
}
}
func handleQuicRequest(conn quic.Connection) {
defer conn.CloseWithError(0, "Closing connection")
// 入口连接只等待第一条 stream该 stream 承载完整 Minecraft 字节流。
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
stream, err := conn.AcceptStream(ctx)
if err != nil {
log.Err(err).Msg("Error accepting stream")
return
}
handleRequest(quicConn{
Connection: conn,
Stream: stream,
})
}
func generateTLSConfig() (*tls.Config, error) {
// 生成临时私钥;当前 QUIC 入口不依赖磁盘证书文件。
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
// 创建自签证书模板,满足 QUIC TLS 握手要求。
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Example Org"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // 有效期 1 年。
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// 自签名证书用于当前进程生命周期内的 QUIC 监听。
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, err
}
// 编码证书和私钥,再交给 tls.X509KeyPair 解析为标准证书结构。
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, err
}
keyPEMBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyPEM})
// 加载到 tls.Certificate。
cert, err := tls.X509KeyPair(certPEM, keyPEMBlock)
if err != nil {
return nil, err
}
// 返回 QUIC listener 使用的 TLS 配置。
return &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: getQuicNextProtos(),
}, nil
}
func getQuicNextProtos() []string {
nextProtos := config.Quic.ApplicationProtocols
if len(nextProtos) == 0 {
return []string{"minecraft", "quic", "raw", "h3"} // 默认协议列表。
}
return nextProtos
}
func (c quicConn) Close() error {
_ = c.Stream.Close()
return c.Connection.CloseWithError(0, "Closing QUIC connection")
}
func (c quicConn) CloseWrite() error {
// QUIC stream 关闭写方向即可通知对端没有更多数据。
return c.Stream.Close()
}
func (c quicConn) CloseRead() error {
// CancelRead 用于停止接收方向,匹配 relay.go 中的半关闭调用。
c.Stream.CancelRead(0)
return nil
}

53
cmd/gateway/quic_test.go Normal file
View File

@@ -0,0 +1,53 @@
// cmd/gateway/quic_test.go 包含用于约束 quic 行为的测试。
package main
import (
"crypto/x509"
"reflect"
"testing"
)
func TestGetQuicNextProtos(t *testing.T) {
defer saveGatewayState(t)()
wantDefault := []string{"minecraft", "quic", "raw", "h3"}
if got := getQuicNextProtos(); !reflect.DeepEqual(got, wantDefault) {
t.Fatalf("getQuicNextProtos() = %v, want %v", got, wantDefault)
}
config.Quic.ApplicationProtocols = []string{"minecraft", "custom"}
if got := getQuicNextProtos(); !reflect.DeepEqual(got, config.Quic.ApplicationProtocols) {
t.Fatalf("getQuicNextProtos() = %v, want %v", got, config.Quic.ApplicationProtocols)
}
}
func TestGenerateTLSConfig(t *testing.T) {
defer saveGatewayState(t)()
config.Quic.ApplicationProtocols = []string{"minecraft", "custom"}
tlsConfig, err := generateTLSConfig()
if err != nil {
t.Fatalf("generateTLSConfig() error = %v", err)
}
if len(tlsConfig.Certificates) != 1 {
t.Fatalf("certificates len = %d, want 1", len(tlsConfig.Certificates))
}
if !reflect.DeepEqual(tlsConfig.NextProtos, config.Quic.ApplicationProtocols) {
t.Fatalf("NextProtos = %v, want %v", tlsConfig.NextProtos, config.Quic.ApplicationProtocols)
}
cert, err := x509.ParseCertificate(tlsConfig.Certificates[0].Certificate[0])
if err != nil {
t.Fatalf("ParseCertificate() error = %v", err)
}
if !cert.IsCA && !cert.BasicConstraintsValid {
t.Fatal("generated certificate has invalid basic constraints")
}
if len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
t.Fatalf("ExtKeyUsage = %v, want server auth", cert.ExtKeyUsage)
}
if !cert.NotAfter.After(cert.NotBefore) {
t.Fatalf("certificate validity range is invalid: %v - %v", cert.NotBefore, cert.NotAfter)
}
}

137
cmd/gateway/relay.go Normal file
View File

@@ -0,0 +1,137 @@
// cmd/gateway/relay.go 实现客户端与上游之间的双向复制循环和转发缓冲池。
package main
import (
"errors"
"io"
"sync"
"github.com/rs/zerolog/log"
)
const proxyBufferSize = 64 * 1024
// proxyBufferPool 为普通 io.CopyBuffer 路径复用 64KiB 缓冲区,降低长连接转发时的分配压力。
var proxyBufferPool = sync.Pool{
New: func() any {
buf := make([]byte, proxyBufferSize)
return &buf
},
}
type (
closeWriter interface {
CloseWrite() error
}
closeReader interface {
CloseRead() error
}
)
func proxyConnections(a, b io.ReadWriter) {
var wg sync.WaitGroup
// 两个方向独立复制,任意一侧读到 EOF 后通过半关闭通知对端。
wg.Add(2)
go func() {
defer wg.Done()
proxyCopy(b, a)
}()
go func() {
defer wg.Done()
proxyCopy(a, b)
}()
wg.Wait()
}
func proxyCopy(dst io.Writer, src io.Reader) {
// 转发协程不能把 panic 带出到连接处理主协程;记录后关闭对应方向即可。
defer recoverProxyCopy()
defer closeRead(src)
defer closeWrite(dst)
_, err := copyForward(dst, src)
if err != nil && !errors.Is(err, io.EOF) {
log.Debug().Err(err).Msg("proxy copy stopped")
}
}
func copyForward(dst io.Writer, src io.Reader) (int64, error) {
// 优先使用标准库为具体类型提供的零拷贝/优化路径,只有普通 reader/writer
// 才落到共享缓冲区。
if _, ok := src.(io.WriterTo); ok {
return io.Copy(dst, src)
}
if _, ok := dst.(io.ReaderFrom); ok {
return io.Copy(dst, src)
}
buf := getProxyBuffer()
defer putProxyBuffer(buf)
return io.CopyBuffer(dst, src, buf)
}
func getProxyBuffer() []byte {
return *proxyBufferPool.Get().(*[]byte)
}
func putProxyBuffer(buf []byte) {
if cap(buf) != proxyBufferSize {
return
}
buf = buf[:proxyBufferSize]
proxyBufferPool.Put(&buf)
}
func writeAll(w io.Writer, buf []byte) error {
// net.Conn.Write 允许短写;首包回放和 PROXY 头写入必须循环直到写完。
for len(buf) > 0 {
n, err := w.Write(buf)
if n > 0 {
buf = buf[n:]
}
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
}
return nil
}
func closeWrite(conn any) {
// TCP 支持半关闭时只关闭写方向,让反向复制还有机会读完剩余数据。
if closer, ok := conn.(closeWriter); ok {
if err := closer.CloseWrite(); err != nil {
log.Debug().Err(err).Msg("failed to close write side")
}
return
}
if closer, ok := conn.(io.Closer); ok {
if err := closer.Close(); err != nil {
log.Debug().Err(err).Msg("failed to close connection")
}
}
}
func closeRead(conn any) {
// 支持 CloseRead 的连接可以显式停止读方向,帮助对端更快感知转发结束。
if closer, ok := conn.(closeReader); ok {
if err := closer.CloseRead(); err != nil {
log.Debug().Err(err).Msg("failed to close read side")
}
}
}
func recoverProxyCopy() {
if rec := recover(); rec != nil {
log.Error().Any("panic", rec).Msg("panic in proxy copy")
}
}

View File

@@ -0,0 +1,427 @@
// cmd/gateway/relay_benchmark_test.go 包含用于约束 relay benchmark 行为的测试。
package main
import (
"bytes"
"errors"
"io"
"net"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/plugin/api"
)
const benchmarkPayloadSize = 4 << 20
func BenchmarkForwardCopy(b *testing.B) {
disableBenchmarkLogs(b)
b.Run("stdlib_io_copy", func(b *testing.B) {
benchmarkCopy(b, func(dst io.Writer, src io.Reader) {
if _, err := io.Copy(dst, src); err != nil {
b.Fatal(err)
}
})
})
b.Run("pooled_copy_buffer", func(b *testing.B) {
benchmarkCopy(b, func(dst io.Writer, src io.Reader) {
proxyCopy(dst, src)
})
})
}
func BenchmarkTCPForwardCopy(b *testing.B) {
disableBenchmarkLogs(b)
b.Run("generic_user_buffer", func(b *testing.B) {
benchmarkTCPForward(b, func(dst, src *net.TCPConn) error {
defer closeRead(src)
defer closeWrite(dst)
buf := getProxyBuffer()
defer putProxyBuffer(buf)
_, err := io.CopyBuffer(
benchmarkTCPWriterOnly{conn: dst},
benchmarkTCPReaderOnly{conn: src},
buf,
)
return err
})
})
b.Run("tcp_fast_path", func(b *testing.B) {
benchmarkTCPForward(b, func(dst, src *net.TCPConn) error {
proxyCopy(dst, src)
return nil
})
})
}
func BenchmarkMapToHostInitialPacket(b *testing.B) {
disableBenchmarkLogs(b)
const (
hostName = "dev.example"
upstreamHost = "benchmark-upstream"
)
packet := benchmarkHandshakePacket(hostName)
source := newBenchmarkConn(benchmarkAddr("client:25565"))
upstream := newBenchmarkConn(benchmarkAddr("upstream:25565"))
publishRouteSnapshot(map[string]string{hostName: upstreamHost})
defer publishRouteSnapshot(nil)
restore := installBenchmarkUpstreamHook(b, hostName, upstreamHost, upstream)
defer restore()
b.SetBytes(int64(len(packet)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
source.ResetReader(packet)
upstream.ResetWriter()
client := mapToHost(source)
if client == nil {
b.Fatal("mapToHost returned nil")
}
if upstream.Written() != int64(len(packet)) {
b.Fatalf("upstream wrote %d bytes, want %d", upstream.Written(), len(packet))
}
}
}
func benchmarkCopy(b *testing.B, copyFunc func(io.Writer, io.Reader)) {
reader := newBenchmarkReader(benchmarkPayloadSize)
writer := &benchmarkWriter{}
b.SetBytes(benchmarkPayloadSize)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
reader.Reset(benchmarkPayloadSize)
writer.Reset()
copyFunc(writer, reader)
if writer.Written() != benchmarkPayloadSize {
b.Fatalf("copied %d bytes, want %d", writer.Written(), benchmarkPayloadSize)
}
}
}
func benchmarkTCPForward(b *testing.B, copyFunc func(dst, src *net.TCPConn) error) {
b.SetBytes(benchmarkPayloadSize)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
srcRelay, srcWriter := newBenchmarkTCPConnPair(b)
dstReader, dstRelay := newBenchmarkTCPConnPair(b)
setBenchmarkTCPDeadline(b, srcRelay, srcWriter, dstReader, dstRelay)
start := make(chan struct{})
writerDone := make(chan error, 1)
readerDone := make(chan benchmarkTCPReadResult, 1)
go benchmarkTCPWrite(start, srcWriter, writerDone)
go benchmarkTCPRead(start, dstReader, readerDone)
b.StartTimer()
close(start)
copyErr := copyFunc(dstRelay, srcRelay)
writeErr := <-writerDone
readResult := <-readerDone
b.StopTimer()
closeBenchmarkTCPConns(srcRelay, srcWriter, dstReader, dstRelay)
if copyErr != nil && !errors.Is(copyErr, io.EOF) {
b.Fatalf("copy failed: %v", copyErr)
}
if writeErr != nil {
b.Fatalf("source write failed: %v", writeErr)
}
if readResult.err != nil {
b.Fatalf("destination read failed: %v", readResult.err)
}
if readResult.n != benchmarkPayloadSize {
b.Fatalf("forwarded %d bytes, want %d", readResult.n, benchmarkPayloadSize)
}
}
}
func benchmarkTCPWrite(start <-chan struct{}, conn *net.TCPConn, done chan<- error) {
<-start
reader := newBenchmarkReader(benchmarkPayloadSize)
buf := getProxyBuffer()
_, err := io.CopyBuffer(conn, reader, buf)
putProxyBuffer(buf)
if closeErr := conn.CloseWrite(); err == nil {
err = closeErr
}
done <- err
}
func benchmarkTCPRead(start <-chan struct{}, conn *net.TCPConn, done chan<- benchmarkTCPReadResult) {
<-start
n, err := io.Copy(io.Discard, conn)
done <- benchmarkTCPReadResult{n: n, err: err}
}
func benchmarkHandshakePacket(host string) []byte {
packet := make([]byte, 5+len(host)+2)
packet[4] = byte(len(host))
copy(packet[5:], host)
return packet
}
func installBenchmarkUpstreamHook(b *testing.B, hostName, upstreamHost string, upstream net.Conn) func() {
b.Helper()
previousConfig := config
previousHooks := hooks
pluginLock.Lock()
hooks = map[string]map[string]any{
"benchmark": {},
}
pluginLock.Unlock()
gateway := &Gateway{pluginId: "benchmark"}
if err := api.RegisterHookHandler(
gateway,
api.HookUpstream,
func(_ net.Conn, host string) bool {
return host == upstreamHost
},
func(_ net.Conn, _ string) (net.Conn, error) {
return upstream, nil
},
); err != nil {
b.Fatal(err)
}
return func() {
config = previousConfig
pluginLock.Lock()
hooks = previousHooks
pluginLock.Unlock()
}
}
func newBenchmarkTCPConnPair(b *testing.B) (*net.TCPConn, *net.TCPConn) {
b.Helper()
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
b.Fatal(err)
}
defer listener.Close()
accepted := make(chan *net.TCPConn, 1)
acceptErr := make(chan error, 1)
go func() {
conn, err := listener.AcceptTCP()
if err != nil {
acceptErr <- err
return
}
accepted <- conn
}()
client, err := net.DialTCP("tcp4", nil, listener.Addr().(*net.TCPAddr))
if err != nil {
b.Fatal(err)
}
select {
case err := <-acceptErr:
client.Close()
b.Fatal(err)
case server := <-accepted:
setSocketOptions(server)
setSocketOptions(client)
return server, client
}
return nil, nil
}
func setBenchmarkTCPDeadline(b *testing.B, conns ...*net.TCPConn) {
b.Helper()
deadline := time.Now().Add(30 * time.Second)
for _, conn := range conns {
if err := conn.SetDeadline(deadline); err != nil {
b.Fatal(err)
}
}
}
func closeBenchmarkTCPConns(conns ...*net.TCPConn) {
for _, conn := range conns {
conn.Close()
}
}
func disableBenchmarkLogs(b *testing.B) {
b.Helper()
previousLogger := log.Logger
log.Logger = zerolog.Nop()
b.Cleanup(func() {
log.Logger = previousLogger
})
}
type benchmarkTCPReadResult struct {
n int64
err error
}
type benchmarkTCPReaderOnly struct {
conn *net.TCPConn
}
func (r benchmarkTCPReaderOnly) Read(p []byte) (int, error) {
return r.conn.Read(p)
}
type benchmarkTCPWriterOnly struct {
conn *net.TCPConn
}
func (w benchmarkTCPWriterOnly) Write(p []byte) (int, error) {
return w.conn.Write(p)
}
type benchmarkReader struct {
chunk []byte
remaining int64
}
func newBenchmarkReader(size int64) *benchmarkReader {
chunk := bytes.Repeat([]byte{0x5a}, 64*1024)
return &benchmarkReader{
chunk: chunk,
remaining: size,
}
}
func (r *benchmarkReader) Reset(size int64) {
r.remaining = size
}
func (r *benchmarkReader) Read(p []byte) (int, error) {
if r.remaining == 0 {
return 0, io.EOF
}
n := len(p)
if n > len(r.chunk) {
n = len(r.chunk)
}
if int64(n) > r.remaining {
n = int(r.remaining)
}
copy(p, r.chunk[:n])
r.remaining -= int64(n)
return n, nil
}
type benchmarkWriter struct {
written int64
}
func (w *benchmarkWriter) Reset() {
w.written = 0
}
func (w *benchmarkWriter) Written() int64 {
return w.written
}
func (w *benchmarkWriter) Write(p []byte) (int, error) {
w.written += int64(len(p))
return len(p), nil
}
type benchmarkConn struct {
reader bytes.Reader
writer benchmarkWriter
addr net.Addr
}
func newBenchmarkConn(addr net.Addr) *benchmarkConn {
return &benchmarkConn{addr: addr}
}
func (c *benchmarkConn) ResetReader(buf []byte) {
c.reader.Reset(buf)
}
func (c *benchmarkConn) ResetWriter() {
c.writer.Reset()
}
func (c *benchmarkConn) Written() int64 {
return c.writer.Written()
}
func (c *benchmarkConn) Read(p []byte) (int, error) {
return c.reader.Read(p)
}
func (c *benchmarkConn) Write(p []byte) (int, error) {
return c.writer.Write(p)
}
func (c *benchmarkConn) Close() error {
return nil
}
func (c *benchmarkConn) LocalAddr() net.Addr {
return c.addr
}
func (c *benchmarkConn) RemoteAddr() net.Addr {
return c.addr
}
func (c *benchmarkConn) SetDeadline(time.Time) error {
return nil
}
func (c *benchmarkConn) SetReadDeadline(time.Time) error {
return nil
}
func (c *benchmarkConn) SetWriteDeadline(time.Time) error {
return nil
}
type benchmarkAddr string
func (a benchmarkAddr) Network() string {
return "tcp"
}
func (a benchmarkAddr) String() string {
return string(a)
}

209
cmd/gateway/relay_test.go Normal file
View File

@@ -0,0 +1,209 @@
// cmd/gateway/relay_test.go 包含用于约束 relay 行为的测试。
package main
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func TestWriteAll(t *testing.T) {
t.Run("partial writes", func(t *testing.T) {
writer := &partialGatewayWriter{chunkSize: 2}
if err := writeAll(writer, []byte("abcdef")); err != nil {
t.Fatalf("writeAll() error = %v", err)
}
if got := writer.buf.String(); got != "abcdef" {
t.Fatalf("written data = %q, want abcdef", got)
}
})
t.Run("short write", func(t *testing.T) {
if err := writeAll(shortGatewayWriter{}, []byte("abcdef")); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("writeAll() error = %v, want %v", err, io.ErrShortWrite)
}
})
t.Run("writer error", func(t *testing.T) {
wantErr := errors.New("write failed")
writer := &partialGatewayWriter{chunkSize: 2, err: wantErr}
if err := writeAll(writer, []byte("abcdef")); !errors.Is(err, wantErr) {
t.Fatalf("writeAll() error = %v, want %v", err, wantErr)
}
if got := writer.buf.String(); got != "ab" {
t.Fatalf("written data = %q, want ab", got)
}
})
}
func TestCopyForwardCopiesData(t *testing.T) {
var dst bytes.Buffer
n, err := copyForward(&dst, strings.NewReader("payload"))
if err != nil {
t.Fatalf("copyForward() error = %v", err)
}
if n != int64(len("payload")) {
t.Fatalf("copyForward() n = %d, want %d", n, len("payload"))
}
if got := dst.String(); got != "payload" {
t.Fatalf("copyForward() data = %q, want payload", got)
}
}
func TestCopyForwardWithPlainReaderWriter(t *testing.T) {
dst := &plainGatewayWriter{}
n, err := copyForward(dst, &plainGatewayReader{data: []byte("plain")})
if err != nil {
t.Fatalf("copyForward() error = %v", err)
}
if n != int64(len("plain")) {
t.Fatalf("copyForward() n = %d, want %d", n, len("plain"))
}
if got := dst.buf.String(); got != "plain" {
t.Fatalf("copyForward() data = %q, want plain", got)
}
}
func TestProxyCopyClosesSides(t *testing.T) {
src := &closingGatewayReader{}
dst := &closingGatewayWriter{}
proxyCopy(dst, src)
if !src.closeReadCalled {
t.Fatal("CloseRead was not called")
}
if !dst.closeWriteCalled {
t.Fatal("CloseWrite was not called")
}
}
func TestProxyCopyRecoversAndClosesSides(t *testing.T) {
src := &panicGatewayReader{}
dst := &closingGatewayWriter{}
proxyCopy(dst, src)
if !src.closeReadCalled {
t.Fatal("CloseRead was not called after panic")
}
if !dst.closeWriteCalled {
t.Fatal("CloseWrite was not called after panic")
}
}
func TestCloseWriteFallsBackToClose(t *testing.T) {
closer := &gatewayCloser{}
closeWrite(closer)
if !closer.closed {
t.Fatal("Close was not called")
}
}
func TestGetAndPutProxyBuffer(t *testing.T) {
buf := getProxyBuffer()
if len(buf) != proxyBufferSize {
t.Fatalf("buffer len = %d, want %d", len(buf), proxyBufferSize)
}
if cap(buf) != proxyBufferSize {
t.Fatalf("buffer cap = %d, want %d", cap(buf), proxyBufferSize)
}
putProxyBuffer(buf[:1])
putProxyBuffer(make([]byte, 1))
}
type partialGatewayWriter struct {
chunkSize int
err error
buf bytes.Buffer
}
func (w *partialGatewayWriter) Write(p []byte) (int, error) {
if len(p) > w.chunkSize {
p = p[:w.chunkSize]
}
n, _ := w.buf.Write(p)
if w.err != nil {
return n, w.err
}
return n, nil
}
type shortGatewayWriter struct{}
func (shortGatewayWriter) Write([]byte) (int, error) {
return 0, nil
}
type plainGatewayReader struct {
data []byte
}
func (r *plainGatewayReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, nil
}
type plainGatewayWriter struct {
buf bytes.Buffer
}
func (w *plainGatewayWriter) Write(p []byte) (int, error) {
return w.buf.Write(p)
}
type closingGatewayReader struct {
closeReadCalled bool
}
func (r *closingGatewayReader) Read([]byte) (int, error) {
return 0, io.EOF
}
func (r *closingGatewayReader) CloseRead() error {
r.closeReadCalled = true
return nil
}
type panicGatewayReader struct {
closeReadCalled bool
}
func (r *panicGatewayReader) Read([]byte) (int, error) {
panic("read panic")
}
func (r *panicGatewayReader) CloseRead() error {
r.closeReadCalled = true
return nil
}
type closingGatewayWriter struct {
closeWriteCalled bool
}
func (w *closingGatewayWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w *closingGatewayWriter) CloseWrite() error {
w.closeWriteCalled = true
return nil
}
type gatewayCloser struct {
closed bool
}
func (c *gatewayCloser) Close() error {
c.closed = true
return nil
}

70
cmd/gateway/tcp.go Normal file
View File

@@ -0,0 +1,70 @@
// cmd/gateway/tcp.go 在未与 Admin HTTP 共用端口时启动普通 TCP Minecraft 监听器。
package main
import (
"fmt"
"net"
"sync"
"time"
"github.com/rs/zerolog/log"
)
func runTcp(wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
port := normalizedTCPPort()
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal().Err(err).
Int("port", port).
Msg("Failed to listen on port")
}
defer listener.Close()
log.Info().
Int("port", port).
Msg("Listening for TCP connections")
for {
// 接受传入的连接
conn, err := listener.Accept()
if err != nil {
log.Err(err).Msg("Error accepting")
continue
}
setSocketOptions(conn)
// 处理连接;后续握手解析、插件过滤和路由解析都在 handleRequest 中完成。
gatewayMetrics.TCPConnectionStarted()
go handleRequest(conn)
}
}
func upstreamTcp(host string) net.Conn {
// TCP 是默认上游传输,路由值没有协议前缀时都会走这里。
conn, err := tcpDialer.Dial("tcp", host)
if err != nil {
gatewayMetrics.UpstreamDialError()
log.Err(err).Str("host", host).Msg("Error dialing upstream")
return nil
}
setSocketOptions(conn)
return conn
}
var tcpDialer = net.Dialer{
// 上游拨号失败应尽快返回给客户端连接处理流程,避免连接协程长期堆积。
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}
func setSocketOptions(conn net.Conn) {
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法,降低 Minecraft 交互延迟。
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
}

61
cmd/gateway/tcp_test.go Normal file
View File

@@ -0,0 +1,61 @@
// cmd/gateway/tcp_test.go 包含用于约束 tcp 行为的测试。
package main
import (
"net"
"testing"
"time"
)
func TestSetSocketOptions(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setSocketOptions(client)
}
func TestUpstreamTcp(t *testing.T) {
defer saveGatewayState(t)()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
defer listener.Close()
accepted := make(chan net.Conn, 1)
errCh := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
errCh <- err
return
}
accepted <- conn
}()
conn := upstreamTcp(listener.Addr().String())
if conn == nil {
t.Fatal("upstreamTcp() = nil, want connection")
}
defer conn.Close()
select {
case serverConn := <-accepted:
serverConn.Close()
case err := <-errCh:
t.Fatalf("Accept() error = %v", err)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for upstream TCP connection")
}
}
func TestUpstreamTcpReturnsNilOnDialError(t *testing.T) {
defer saveGatewayState(t)()
if got := upstreamTcp("not a tcp address"); got != nil {
t.Fatalf("upstreamTcp() = %v, want nil", got)
}
}

View File

@@ -0,0 +1,103 @@
// cmd/gateway/tcp_web_port_reuse.go 启动共享 TCP/Admin 监听器,按连接首包自动区分 HTTP 流量和 Minecraft 流量。
package main
import (
"errors"
"fmt"
"net"
"net/http"
"sync"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/tcphttpmux"
)
const (
defaultTCPPort = 25565
// 首包超时沿用 tcphttpmux 默认值,保持同端口分流逻辑的单一来源。
tcpWebInitialPacketTimeout = tcphttpmux.DefaultInitialPacketTimeout
)
func normalizedTCPPort() int {
// 静态配置未指定端口时保持 Minecraft 默认端口。
if config.Tcp.Port == 0 {
return defaultTCPPort
}
return config.Tcp.Port
}
func normalizedWebSocketPort() int {
if config.WebSocket.Port == 0 {
return defaultWebSocketPort
}
return config.WebSocket.Port
}
func normalizedWebSocketPath() string {
// WebSocket 路径为空时回退到根路径,避免生成空的 HTTP 路由。
if config.WebSocket.Path == "" {
return "/"
}
return config.WebSocket.Path
}
func tcpWebPortReuseEnabled() bool {
// 是否共用端口完全由启用状态和端口相等推导,不引入额外配置开关。
return config.Tcp.Enable &&
config.WebSocket.Enable &&
normalizedTCPPort() == normalizedWebSocketPort()
}
func runTcpWebPortReuse(wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
port := normalizedTCPPort()
// 同一个 listener 同时承载 Minecraft TCP 和 Admin HTTP由 serveTcpWebPortReuse 分流。
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal().Err(err).
Int("port", port).
Msg("Failed to listen on shared TCP/WebSocket port")
}
log.Info().
Int("port", port).
Str("admin_path", adminStartup.AdminPath).
Msg("Listening for shared TCP and Admin connections")
if err := serveTcpWebPortReuse(listener, newGatewayHTTPHandler(), handleRequest); err != nil && !errors.Is(err, net.ErrClosed) {
log.Fatal().Err(err).
Int("port", port).
Msg("Shared TCP/Admin server stopped")
}
}
func serveTcpWebPortReuse(listener net.Listener, handler http.Handler, tcpHandler func(net.Conn)) error {
// tcphttpmux 只负责协议分流指标、socket 选项和日志通过回调接回主包。
return tcphttpmux.Serve(listener, handler, tcpHandler, tcphttpmux.Options{
InitialPacketTimeout: tcpWebInitialPacketTimeout,
SetSocketOptions: setSocketOptions,
OnTCPConnection: gatewayMetrics.TCPConnectionStarted,
OnAcceptError: func(err error) {
log.Err(err).Msg("Error accepting shared TCP/WebSocket connection")
},
OnInitialPacketError: func(conn net.Conn, err error) {
log.Debug().Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("failed to read initial packet")
},
OnEmptyInitialPacket: func(conn net.Conn) {
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("initial packet is empty")
},
OnHTTPDeliveryFailed: func(conn net.Conn) {
log.Debug().
Str("client", conn.RemoteAddr().String()).
Msg("failed to deliver HTTP connection")
},
})
}

View File

@@ -0,0 +1,218 @@
// cmd/gateway/tcp_web_port_reuse_test.go 包含用于约束 tcp web port reuse 行为的测试。
package main
import (
"errors"
"net"
"net/http"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
"github.com/tursom/mc-gateway/protocol"
)
func TestTCPWebPortReuseEnabledUsesNormalizedPorts(t *testing.T) {
tests := []struct {
name string
tcp gatewayconfig.ProtocolConfig
websocket gatewayconfig.WebSocketConfig
want bool
}{
{
name: "explicit same port",
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
want: true,
},
{
name: "websocket default port",
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: defaultWebSocketPort},
websocket: gatewayconfig.WebSocketConfig{Enable: true},
want: true,
},
{
name: "tcp default port",
tcp: gatewayconfig.ProtocolConfig{Enable: true},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: defaultTCPPort},
want: true,
},
{
name: "different ports",
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25566},
want: false,
},
{
name: "tcp disabled",
tcp: gatewayconfig.ProtocolConfig{Enable: false, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: true, Port: 25565},
want: false,
},
{
name: "websocket disabled",
tcp: gatewayconfig.ProtocolConfig{Enable: true, Port: 25565},
websocket: gatewayconfig.WebSocketConfig{Enable: false, Port: 25565},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer saveGatewayState(t)()
config.Tcp = tt.tcp
config.WebSocket = tt.websocket
if got := tcpWebPortReuseEnabled(); got != tt.want {
t.Fatalf("tcpWebPortReuseEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestServeTcpWebPortReuseServesHTTPOnSharedPort(t *testing.T) {
defer saveGatewayState(t)()
requested := make(chan string, 1)
addr, stop := startPortReuseTestServer(
t,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested <- r.URL.Path
w.WriteHeader(http.StatusNoContent)
}),
func(conn net.Conn) {
conn.Close()
},
)
defer stop()
client := &http.Client{
Timeout: 2 * time.Second,
Transport: &http.Transport{Proxy: nil},
}
resp, err := client.Get("http://" + addr + "/status")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("HTTP status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
select {
case got := <-requested:
if got != "/status" {
t.Fatalf("request path = %q, want /status", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for HTTP request")
}
}
func TestServeTcpWebPortReuseUpgradesWebSocketOnSharedPort(t *testing.T) {
defer saveGatewayState(t)()
config.WebSocket.Path = "/gateway"
webSocketHandler := newWebSocketHandler()
handlerDone := make(chan struct{})
addr, stop := startPortReuseTestServer(
t,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(handlerDone)
webSocketHandler.ServeHTTP(w, r)
}),
func(conn net.Conn) {
conn.Close()
},
)
defer stop()
conn, _, err := websocket.DefaultDialer.Dial("ws://"+addr+"/gateway", nil)
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
conn.Close()
select {
case <-handlerDone:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for WebSocket handler")
}
}
func TestServeTcpWebPortReusePreservesMinecraftInitialPacket(t *testing.T) {
defer saveGatewayState(t)()
hostCh := make(chan string, 1)
addr, stop := startPortReuseTestServer(
t,
http.NotFoundHandler(),
func(conn net.Conn) {
defer conn.Close()
buf := getProxyBuffer()
defer putProxyBuffer(buf)
n, err := conn.Read(buf)
if err != nil {
hostCh <- ""
return
}
hostCh <- protocol.GetMcHost(buf[:n])
},
)
defer stop()
conn, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("Dial() error = %v", err)
}
defer conn.Close()
if _, err := conn.Write(gatewayTestPacket("play.example")); err != nil {
t.Fatalf("Write() error = %v", err)
}
select {
case got := <-hostCh:
if got != "play.example" {
t.Fatalf("Minecraft host = %q, want play.example", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for Minecraft branch")
}
}
func startPortReuseTestServer(t *testing.T, handler http.Handler, tcpHandler func(net.Conn)) (string, func()) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
done := make(chan error, 1)
go func() {
done <- serveTcpWebPortReuse(listener, handler, tcpHandler)
}()
stop := func() {
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
t.Fatalf("Close(listener) error = %v", err)
}
select {
case err := <-done:
if err != nil && !errors.Is(err, net.ErrClosed) {
t.Fatalf("serveTcpWebPortReuse() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for port reuse server shutdown")
}
}
return listener.Addr().String(), stop
}

View File

@@ -0,0 +1,209 @@
// cmd/gateway/test_helpers_test.go 包含用于约束 test helpers 行为的测试。
package main
import (
"bytes"
"io"
"net"
"os"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/internal/adminconfig"
"github.com/tursom/mc-gateway/internal/adminsession"
"github.com/tursom/mc-gateway/internal/gatewayconfig"
"github.com/tursom/mc-gateway/internal/gatewaymetrics"
"github.com/tursom/mc-gateway/plugin/api"
)
func saveGatewayState(t *testing.T) func() {
t.Helper()
oldConfig := config
oldCurrentPidFile := currentPidFile
oldCurrentLogFile := currentLogFile
oldLogger := log.Logger
oldAdminStartup := adminStartup
oldAdminDB := adminDB
oldAdminDBPath := adminDBPath
oldPluginsManager := pluginsManager
oldAdminSessionManager := adminSessionManager
oldRouteSnapshot := routeSnapshot.Clone()
oldGatewayMetrics := gatewayMetrics
pluginLock.Lock()
oldPlugins := plugins
oldHooks := hooks
plugins = make(map[string]api.Plugin)
hooks = make(map[string]map[string]any)
pluginLock.Unlock()
config = gatewayconfig.Config{}
currentPidFile = ""
currentLogFile = ""
adminStartup = adminconfig.Config{
DBPath: defaultAdminDBPath,
TCPAdminPort: defaultTCPPort,
AdminPath: defaultAdminPath,
AdminAPIPrefix: defaultAdminAPIPrefix,
SessionTTL: defaultAdminSessionTTL,
}
adminDB = nil
adminDBPath = ""
pluginsManager = nil
adminSessionManager = adminsession.NewManager()
publishRouteSnapshot(nil)
gatewayMetrics = gatewaymetrics.New()
log.Logger = zerolog.New(io.Discard)
return func() {
if adminDB != nil && adminDB != oldAdminDB {
_ = adminDB.Close()
}
if currentPidFile != "" && currentPidFile != oldCurrentPidFile {
_ = os.Remove(currentPidFile)
}
config = oldConfig
currentPidFile = oldCurrentPidFile
currentLogFile = oldCurrentLogFile
adminStartup = oldAdminStartup
adminDB = oldAdminDB
adminDBPath = oldAdminDBPath
pluginsManager = oldPluginsManager
adminSessionManager = oldAdminSessionManager
publishRouteSnapshot(oldRouteSnapshot)
gatewayMetrics = oldGatewayMetrics
log.Logger = oldLogger
pluginLock.Lock()
plugins = oldPlugins
hooks = oldHooks
pluginLock.Unlock()
}
}
func setGatewayTestRoutes(routes map[string]string) {
publishRouteSnapshot(routes)
}
func gatewayTestPacket(host string, tail ...byte) []byte {
protocolVersion := byte(0x63)
nextState := byte(0x02)
extra := []byte(nil)
if len(tail) > 0 {
protocolVersion = tail[0]
}
if len(tail) > 1 {
nextState = tail[1]
}
if len(tail) > 2 {
extra = tail[2:]
}
payload := []byte{0x00, protocolVersion, byte(len(host))}
payload = append(payload, host...)
payload = append(payload, 0x63, 0xdd, nextState)
payload = append(payload, extra...)
packet := []byte{byte(len(payload))}
return append(packet, payload...)
}
type gatewayTestConn struct {
mu sync.Mutex
readBuf []byte
readErr error
writeBuf bytes.Buffer
writeErr error
closed bool
local net.Addr
remote net.Addr
}
func newGatewayTestConn(readBuf []byte) *gatewayTestConn {
return &gatewayTestConn{
readBuf: append([]byte(nil), readBuf...),
local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 25565},
remote: &net.TCPAddr{IP: net.ParseIP("127.0.0.2"), Port: 45678},
}
}
func (c *gatewayTestConn) Read(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.readErr != nil {
return 0, c.readErr
}
if len(c.readBuf) == 0 {
return 0, io.EOF
}
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func (c *gatewayTestConn) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.writeErr != nil {
return 0, c.writeErr
}
return c.writeBuf.Write(p)
}
func (c *gatewayTestConn) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
return nil
}
func (c *gatewayTestConn) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
func (c *gatewayTestConn) LocalAddr() net.Addr {
return c.local
}
func (c *gatewayTestConn) RemoteAddr() net.Addr {
return c.remote
}
func (c *gatewayTestConn) SetDeadline(time.Time) error {
return nil
}
func (c *gatewayTestConn) SetReadDeadline(time.Time) error {
return nil
}
func (c *gatewayTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func registerGatewayUpstreamHook(
t *testing.T,
acceptor func(net.Conn, string) bool,
handler func(net.Conn, string) (net.Conn, error),
) {
t.Helper()
pluginLock.Lock()
hooks["test-plugin"] = make(map[string]any)
pluginLock.Unlock()
if err := api.RegisterHookHandler(&Gateway{pluginId: "test-plugin"}, api.HookUpstream, acceptor, handler); err != nil {
t.Fatalf("RegisterHookHandler() error = %v", err)
}
}

124
cmd/gateway/websocket.go Normal file
View File

@@ -0,0 +1,124 @@
// cmd/gateway/websocket.go 把 WebSocket 会话适配为 net.Conn让浏览器客户端复用网关请求路径。
package main
import (
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 当前网关把 WebSocket 当作传输层入口,先允许所有来源;生产暴露时应在反向代理层收紧来源。
return true
},
}
type (
// WebSocket 连接适配器,实现 net.Conn 接口
webSocketConn struct {
*websocket.Conn
reader io.Reader
}
)
// WebSocket 处理函数
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
// 升级 HTTP 连接为 WebSocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Err(err).Msg("Failed to upgrade WebSocket connection")
return
}
defer conn.Close()
gatewayMetrics.WebSocketConnectionStarted()
// WebSocket 连接包装为 net.Conn 后进入同一个 handleRequest复用插件、路由和转发逻辑。
handleRequest(&webSocketConn{Conn: conn})
}
func (w *webSocketConn) Read(b []byte) (n int, err error) {
for {
if w.reader != nil {
// 当前消息帧没读完前持续从同一个 reader 读取,模拟流式 net.Conn。
n, err = w.reader.Read(b)
if errors.Is(err, io.EOF) {
w.reader = nil
if n > 0 {
return n, nil
}
continue
}
return n, err
}
messageType, reader, err := w.NextReader()
if err != nil {
return 0, err
}
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
// 控制帧不进入 Minecraft 协议流。
continue
}
w.reader = reader
}
}
func (w *webSocketConn) Write(b []byte) (n int, err error) {
// 每次 Write 输出一个二进制 WebSocket 消息,保持与 Minecraft packet 边界无关的字节流语义。
writer, err := w.NextWriter(websocket.BinaryMessage)
if err != nil {
return 0, err
}
n, err = writer.Write(b)
closeErr := writer.Close()
if err != nil {
return n, err
}
if closeErr != nil {
return n, closeErr
}
if n != len(b) {
return n, io.ErrShortWrite
}
return n, nil
}
func (w *webSocketConn) SetDeadline(t time.Time) error {
if err := w.SetReadDeadline(t); err != nil {
return err
}
return w.SetWriteDeadline(t)
}
func newWebSocketHandler() http.Handler {
mux := http.NewServeMux()
// 路径来自运行态服务配置,允许管理端把 WebSocket 入口挂到子路径。
mux.HandleFunc(normalizedWebSocketPath(), handleWebSocket)
return mux
}
// 启动 WebSocket 服务器
func runWebSocket(wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
port := normalizedWebSocketPort()
path := normalizedWebSocketPath()
log.Info().Int("port", port).Str("path", path).Msg("Starting WebSocket server")
server := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: newWebSocketHandler()}
if err := server.ListenAndServe(); err != nil {
log.Fatal().Err(err).Msg("Failed to start WebSocket server")
}
}

View File

@@ -0,0 +1,137 @@
// cmd/gateway/websocket_test.go 包含用于约束 websocket 行为的测试。
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestWebSocketConnReadWriteAndDeadline(t *testing.T) {
serverConn, clientConn, cleanup := newGatewayWebSocketPair(t)
defer cleanup()
conn := &webSocketConn{Conn: serverConn}
if err := clientConn.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatalf("client WriteMessage() error = %v", err)
}
buf := make([]byte, len("hello"))
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read() error = %v", err)
}
if got := string(buf[:n]); got != "hello" {
t.Fatalf("Read() = %q, want hello", got)
}
if err := conn.SetDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetDeadline() error = %v", err)
}
payload := []byte("response")
n, err = conn.Write(payload)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
if n != len(payload) {
t.Fatalf("Write() n = %d, want %d", n, len(payload))
}
messageType, got, err := clientConn.ReadMessage()
if err != nil {
t.Fatalf("client ReadMessage() error = %v", err)
}
if messageType != websocket.BinaryMessage {
t.Fatalf("message type = %d, want %d", messageType, websocket.BinaryMessage)
}
if string(got) != string(payload) {
t.Fatalf("message payload = %q, want %q", got, payload)
}
}
func TestWebSocketConnReadContinuesAcrossFrames(t *testing.T) {
serverConn, clientConn, cleanup := newGatewayWebSocketPair(t)
defer cleanup()
conn := &webSocketConn{Conn: serverConn}
if err := clientConn.WriteMessage(websocket.BinaryMessage, []byte("abc")); err != nil {
t.Fatalf("client WriteMessage(first) error = %v", err)
}
if err := clientConn.WriteMessage(websocket.BinaryMessage, []byte("def")); err != nil {
t.Fatalf("client WriteMessage(second) error = %v", err)
}
buf := make([]byte, 2)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read(first) error = %v", err)
}
if got := string(buf[:n]); got != "ab" {
t.Fatalf("Read(first) = %q, want ab", got)
}
n, err = conn.Read(buf)
if err != nil {
t.Fatalf("Read(second) error = %v", err)
}
if got := string(buf[:n]); got != "c" {
t.Fatalf("Read(second) = %q, want c", got)
}
n, err = conn.Read(buf)
if err != nil {
t.Fatalf("Read(third) error = %v", err)
}
if got := string(buf[:n]); got != "de" {
t.Fatalf("Read(third) = %q, want de", got)
}
}
func newGatewayWebSocketPair(t *testing.T) (*websocket.Conn, *websocket.Conn, func()) {
t.Helper()
serverConnCh := make(chan *websocket.Conn, 1)
errCh := make(chan error, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
errCh <- err
return
}
serverConnCh <- conn
}))
url := "ws" + strings.TrimPrefix(server.URL, "http")
clientConn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
server.Close()
t.Fatalf("Dial() error = %v", err)
}
var serverConn *websocket.Conn
select {
case serverConn = <-serverConnCh:
case err := <-errCh:
clientConn.Close()
server.Close()
t.Fatalf("Upgrade() error = %v", err)
case <-time.After(2 * time.Second):
clientConn.Close()
server.Close()
t.Fatal("timed out waiting for websocket upgrade")
}
cleanup := func() {
_ = clientConn.Close()
_ = serverConn.Close()
server.Close()
}
return serverConn, clientConn, cleanup
}

125
cmd/kcp/main.go Normal file
View File

@@ -0,0 +1,125 @@
// cmd/kcp/main.go 提供独立的 KCP 到 TCP 代理工具,用于测试或演示 KCP 传输行为。
package main
import (
"errors"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/protocol"
"github.com/xtaci/kcp-go"
)
var (
localPort = 25565
mcHost = "bh.mc.tursom.cn"
mcPort = 25566
)
func main() {
if len(os.Args) > 1 {
mcHost = os.Args[1]
}
// 监听TCP端口
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort))
if err != nil {
log.Fatal().Err(err).
Int("port", localPort).
Msg("Failed to listen on port")
}
defer listener.Close()
log.Info().
Int("port", localPort).
Msg("Listening for TCP connections")
for {
// 接受传入的连接
conn, err := listener.Accept()
if err != nil {
log.Err(err).Msg("Error accepting")
continue
}
log.Info().
Str("client", conn.RemoteAddr().String()).
Int("port", localPort).
Msg("Accepted connection")
// 处理连接
go handlerConn(conn)
}
}
func handlerConn(client net.Conn) {
defer client.Close()
setSocketOptions(client)
conn, err := kcp.DialWithOptions(fmt.Sprintf("%s:%d", mcHost, mcPort), nil, 10, 5)
if err != nil {
log.Error().Err(err).
Msg("Failed to dial KCP server")
}
defer conn.Close()
conn.SetACKNoDelay(true)
buf := make([]byte, 1024)
n, err := client.Read(buf)
if err != nil {
log.Err(err).
Str("client", client.RemoteAddr().String()).
Msg("Error reading hostname")
return
}
if n == 0 {
log.Err(errors.New("empty buffer")).
Str("client", client.RemoteAddr().String()).
Msg("Error: buffer is empty")
return
}
buf = buf[:n]
buf = protocol.ReplaceMcHost(buf, mcHost)
_, err = conn.Write(buf) // 写入数据到 QUIC 流
if err != nil {
log.Err(err).
Str("client", client.RemoteAddr().String()).
Str("host", mcHost).
Int("port", mcPort).
Msg("Error writing to QUIC stream")
return
}
// 释放 buf
buf = nil
var wg sync.WaitGroup
wg.Add(1)
go copyData(conn, client, &wg)
copyData(client, conn, nil)
wg.Wait()
}
func copyData(src io.Reader, dst io.Writer, wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
_, err := io.Copy(dst, src)
if err != nil && err != io.EOF {
log.Err(err).Msg("Error copying data")
}
}
func setSocketOptions(conn net.Conn) {
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
}

96
cmd/kcp/main_test.go Normal file
View File

@@ -0,0 +1,96 @@
// cmd/kcp/main_test.go 包含用于约束 kcp 行为的测试。
package main
import (
"bytes"
"errors"
"io"
"net"
"strings"
"sync"
"testing"
"time"
)
func TestCopyData(t *testing.T) {
var dst bytes.Buffer
var wg sync.WaitGroup
wg.Add(1)
copyData(strings.NewReader("payload"), &dst, &wg)
wg.Wait()
if got := dst.String(); got != "payload" {
t.Fatalf("copyData() wrote %q, want payload", got)
}
}
func TestCopyDataIgnoresEOFAndLogsOtherErrors(t *testing.T) {
copyData(strings.NewReader(""), io.Discard, nil)
copyData(errorReader{err: errors.New("read failed")}, io.Discard, nil)
}
func TestSetSocketOptions(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setSocketOptions(client)
}
func TestSetSocketOptionsTCPConn(t *testing.T) {
client, server := newKcpTestTCPConnPair(t)
defer client.Close()
defer server.Close()
setSocketOptions(client)
setSocketOptions(server)
}
type errorReader struct {
err error
}
func (r errorReader) Read([]byte) (int, error) {
return 0, r.err
}
func newKcpTestTCPConnPair(t *testing.T) (*net.TCPConn, *net.TCPConn) {
t.Helper()
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatalf("ListenTCP() error = %v", err)
}
defer listener.Close()
accepted := make(chan *net.TCPConn, 1)
errCh := make(chan error, 1)
go func() {
conn, err := listener.AcceptTCP()
if err != nil {
errCh <- err
return
}
accepted <- conn
}()
client, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
if err != nil {
t.Fatalf("DialTCP() error = %v", err)
}
select {
case server := <-accepted:
return client, server
case err := <-errCh:
client.Close()
t.Fatalf("AcceptTCP() error = %v", err)
case <-time.After(2 * time.Second):
client.Close()
t.Fatal("timed out waiting for TCP accept")
}
return nil, nil
}

144
cmd/quic/main.go Normal file
View File

@@ -0,0 +1,144 @@
// cmd/quic/main.go 提供独立的 QUIC 到 TCP 代理工具,用于测试或演示 QUIC 传输行为。
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog/log"
"github.com/tursom/mc-gateway/protocol"
)
var (
localPort = 25565
mcHost = "bh.mc.tursom.cn"
mcPort = 25565
)
func main() {
if len(os.Args) > 1 {
mcHost = os.Args[1]
}
// 监听TCP端口
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", localPort))
if err != nil {
log.Fatal().Err(err).
Int("port", localPort).
Msg("Failed to listen on port")
}
defer listener.Close()
log.Info().
Int("port", localPort).
Msg("Listening for TCP connections")
for {
// 接受传入的连接
conn, err := listener.Accept()
if err != nil {
log.Err(err).Msg("Error accepting")
continue
}
log.Info().
Str("client", conn.RemoteAddr().String()).
Int("port", localPort).
Msg("Accepted connection")
// 处理连接
go handlerConn(conn)
}
}
func handlerConn(conn net.Conn) {
defer conn.Close()
setSocketOptions(conn)
tlsConf := &tls.Config{
InsecureSkipVerify: true, // 跳过证书检查
NextProtos: []string{"minecraft", "quic", "raw", "h3"},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3s handshake timeout
defer cancel()
quicConn, err := quic.DialAddr(ctx, fmt.Sprintf("%s:%d", mcHost, mcPort), tlsConf, nil)
if err != nil {
log.Err(err).Msg("Failed to dial QUIC")
return
}
stream, err := quicConn.OpenStream()
if err != nil {
log.Err(err).Msg("Failed to open stream")
return
}
defer stream.Close()
log.Info().
Msg("QUIC stream opened")
// 读写 QUIC 流数据。
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Msg("Error reading hostname")
return
}
if n == 0 {
log.Err(errors.New("empty buffer")).
Str("client", conn.RemoteAddr().String()).
Msg("Error: buffer is empty")
return
}
buf = buf[:n]
buf = protocol.ReplaceMcHost(buf, mcHost)
_, err = stream.Write(buf) // 写入数据到 QUIC 流
if err != nil {
log.Err(err).
Str("client", conn.RemoteAddr().String()).
Str("host", mcHost).
Int("port", mcPort).
Msg("Error writing to QUIC stream")
return
}
// 释放 buf
buf = nil
var wg sync.WaitGroup
wg.Add(1)
go copyData(stream, conn, &wg)
copyData(conn, stream, nil)
wg.Wait()
}
func copyData(src io.Reader, dst io.Writer, wg *sync.WaitGroup) {
if wg != nil {
defer wg.Done()
}
_, err := io.Copy(dst, src)
if err != nil && err != io.EOF {
log.Err(err).Msg("Error copying data")
}
}
func setSocketOptions(conn net.Conn) {
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true) // 禁用 Nagle 算法
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
}
}

96
cmd/quic/main_test.go Normal file
View File

@@ -0,0 +1,96 @@
// cmd/quic/main_test.go 包含用于约束 quic 行为的测试。
package main
import (
"bytes"
"errors"
"io"
"net"
"strings"
"sync"
"testing"
"time"
)
func TestCopyData(t *testing.T) {
var dst bytes.Buffer
var wg sync.WaitGroup
wg.Add(1)
copyData(strings.NewReader("payload"), &dst, &wg)
wg.Wait()
if got := dst.String(); got != "payload" {
t.Fatalf("copyData() wrote %q, want payload", got)
}
}
func TestCopyDataIgnoresEOFAndLogsOtherErrors(t *testing.T) {
copyData(strings.NewReader(""), io.Discard, nil)
copyData(errorReader{err: errors.New("read failed")}, io.Discard, nil)
}
func TestSetSocketOptions(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
setSocketOptions(client)
}
func TestSetSocketOptionsTCPConn(t *testing.T) {
client, server := newQuicTestTCPConnPair(t)
defer client.Close()
defer server.Close()
setSocketOptions(client)
setSocketOptions(server)
}
type errorReader struct {
err error
}
func (r errorReader) Read([]byte) (int, error) {
return 0, r.err
}
func newQuicTestTCPConnPair(t *testing.T) (*net.TCPConn, *net.TCPConn) {
t.Helper()
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
t.Fatalf("ListenTCP() error = %v", err)
}
defer listener.Close()
accepted := make(chan *net.TCPConn, 1)
errCh := make(chan error, 1)
go func() {
conn, err := listener.AcceptTCP()
if err != nil {
errCh <- err
return
}
accepted <- conn
}()
client, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr))
if err != nil {
t.Fatalf("DialTCP() error = %v", err)
}
select {
case server := <-accepted:
return client, server
case err := <-errCh:
client.Close()
t.Fatalf("AcceptTCP() error = %v", err)
case <-time.After(2 * time.Second):
client.Close()
t.Fatal("timed out waiting for TCP accept")
}
return nil, nil
}

7
compose.override.yaml Normal file
View File

@@ -0,0 +1,7 @@
# compose.override.yaml 把本地 Compose 覆盖项与偏生产形态的基础服务定义分开维护。
services:
mc-gateway:
build:
context: .
dockerfile: Dockerfile

24
compose.yaml Normal file
View File

@@ -0,0 +1,24 @@
# compose.yaml 定义偏生产形态的网关容器、数据卷、端口和避开代理干扰的健康检查。
services:
mc-gateway:
image: ${MC_GATEWAY_IMAGE:-ghcr.io/tursom/mc-gateway:latest}
network_mode: host
restart: unless-stopped
environment:
MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}"
MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}"
MC_GATEWAY_ADMIN_STATIC_DIR: "${MC_GATEWAY_ADMIN_STATIC_DIR:-/usr/share/mc-gateway/admin_static}"
MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}"
MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
volumes:
- ./data:/data
healthcheck:
test:
- CMD-SHELL
- wget -Y off -qO- "http://127.0.0.1:$${MC_GATEWAY_TCP_ADMIN_PORT:-25565}$${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}/setup" >/dev/null
interval: 30s
timeout: 5s
retries: 5
start_period: 10s

View File

@@ -1,8 +0,0 @@
{
"port": 25565,
"default": "mc1:25565",
"hosts": {
"dev": "mc1:25565",
"dev.lan": "mc1:25568"
}
}

27
config.example.toml Normal file
View File

@@ -0,0 +1,27 @@
# config.example.toml 说明启动监听器和运行态默认值所需的静态网关配置字段。
# pid 文件
pid_file = "gateway.pid"
[log]
level = "warn"
file = "logs/mc1.log"
[tcp]
enable = true
port = 25565
[quic]
enable = true
port = 25565
pid_file = ["minecraft", "quic", "raw", "h3"]
[kcp]
enable = true
port = 25566
data_shards = 10
parity_shards = 3
[hosts]
dev = "mc1:25565"
"dev.lan" = "mc1:25568"

436
docs/admin-page-design.md Normal file
View File

@@ -0,0 +1,436 @@
# 管理页面设计
## 背景
当前 gateway 已经具备 TCP 与 HTTP/WebSocket 的端口复用能力:
- Minecraft 原始 TCP 连接进入 `handleRequest`,再按握手包里的 host 路由到上游。
- WebSocket 连接先进入 HTTP handler再 upgrade 为 WebSocket最后同样复用 `handleRequest`
- `tcp_web_port_reuse.go` 已经提供首包识别、连接回放和 `http.Server.Serve(chanListener)` 这条分流路径。
新的管理方案不再以 `config.toml` 为中心。进程默认启动即可工作:
- 默认在 `25565` 端口启动 Minecraft TCP 监听。
- 同一个 `25565` 端口同时承载后台管理页面和管理 API。
- 用户、权限、路由、其他协议服务配置都持久化到 SQLite。
- 其他服务和路由通过后台管理配置,不再依赖配置文件。
## 功能需求对齐
第一版管理页面按以下边界设计。
### 必须支持
- 进程启动不依赖配置文件;缺省状态下直接监听 `25565`
- TCP 转发和后台管理默认共用同一个 TCP listener。
- 管理入口必须有认证保护。复用公网 Minecraft 端口时,不能出现无认证管理 API。
- 管理页面内置用户和权限系统,角色分为管理员、成员和游客。
- 所有持久化状态进入 SQLite包括用户、路由、服务配置和审计日志。
- 页面提供一个实际可用的后台界面,而不是只返回 JSON
- 概览:进程 PID、运行时长、SQLite 路径、TCP/Admin 端口、各协议服务状态。
- 路由管理:展示、搜索、新增、修改、删除 SQLite 中的路由记录。
- 服务管理:配置 KCP、QUIC、WebSocket 的启用状态、端口和协议参数。
- 用户管理:管理员可以新增、禁用、修改角色和重置密码。
- 连接统计:展示总连接数、当前活跃连接数、路由命中次数、上游连接失败次数。
- 游客登录后可以查看当前 SQLite 路由记录,但不能修改路由、配置服务或查看用户列表。
- 路由记录必须持久化到 SQLite进程重启后仍然生效。
- 服务配置必须持久化到 SQLite进程重启后按后台配置恢复。
- 所有写操作需要记录审计日志,至少包含操作者、来源 IP、操作类型、目标对象和结果。
### 暂不支持
- 不再支持 `config.toml` 作为启动配置、热加载配置或路由来源。
- 不做多租户、组织空间和自定义细粒度权限。
- 不做 HTTPS/TLS 终止;需要 HTTPS 时由外部反向代理或负载均衡器处理。
- 不做 Minecraft 玩家在线列表、踢人、封禁等游戏服管理能力。
- 不做插件管理、二进制升级、进程重启。
- 不把每条连接的完整客户端地址、目标地址长期保存在内存里。
## 默认启动设计
无配置文件时使用以下默认值:
| 项 | 默认值 | 环境变量 |
| --- | --- | --- |
| SQLite 数据库 | `mc-gateway.sqlite3` | `MC_GATEWAY_DB` |
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
| Admin 静态目录 | `cmd/gateway/admin_static` | `MC_GATEWAY_ADMIN_STATIC_DIR` |
| KCP | 默认禁用 | 后台配置 |
| QUIC | 默认禁用 | 后台配置 |
| WebSocket | 默认禁用 | 后台配置 |
| 路由表 | 默认空表 | 后台配置 |
| 会话有效期 | 8 小时 | 后台配置 |
启动期环境变量规则:
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`
- `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api`
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下,例如 `config.js``js/`
- `MC_GATEWAY_ADMIN_STATIC_DIR` 指向 Admin 前端构建产物目录Docker 镜像内使用 `/usr/share/mc-gateway/admin_static`
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`
启动流程:
1. 读取并校验启动期环境变量,得到 SQLite 路径、TCP/Admin 端口、Admin 页面路径、Admin API 前缀。
2. 打开 SQLite 数据库;不存在时自动创建。
3. 执行 schema migration。
4. 确保默认服务配置存在TCP/Admin listener 启用,端口为启动期解析后的端口。
5. 如果用户表为空,进入首次初始化模式。
6. 加载启用的路由记录为内存只读快照。
7. 启动 TCP/Admin 共享 listener。
8. 按 SQLite 中的服务配置启动 KCP、QUIC、WebSocket。
首次初始化:
- 当用户表为空时,启动期解析后的 Admin 页面路径显示初始化管理员页面。
- 初始化接口只允许创建第一个管理员账号。
- 第一个管理员创建成功后,初始化接口永久关闭。
- 也可以通过环境变量 `MC_GATEWAY_ADMIN_PASSWORD` 配合默认用户名 `admin` 在启动时创建初始管理员;这不是配置文件,只是无交互部署入口。
SQLite 路径:
- 默认使用当前工作目录下的 `mc-gateway.sqlite3`
- 如需改路径,第一版只接受启动参数或环境变量,例如 `MC_GATEWAY_DB`;不引入配置文件。
## 运行模型
TCP/Admin listener 是基础入口:
```text
net.Listen(:startup env port or db service setting)
|
v
Accept
|
v
读取首批字节
|
+-- HTTP/Admin/WebSocket
| -> HTTP channel listener
| -> http.Server.Serve
|
+-- Minecraft TCP
-> 回放首批字节
-> handleRequest
-> mapToHost
-> route snapshot
-> proxyConnections
```
核心规则:
- TCP/Admin listener 默认永远启用,避免后台管理入口丢失。
- TCP/Admin 端口可以由启动期环境变量覆盖,也可以保存在 SQLite最终监听端口以启动期解析结果优先。
- TCP/Admin 端口修改后第一版按重启后生效处理。
- Admin 页面路径和 API 前缀只由启动期环境变量控制,不通过后台页面修改,避免运行中替换管理入口导致当前会话失效。
- KCP、QUIC、WebSocket 由后台管理配置启用状态和端口。
- 如果某个服务的端口或协议参数无法热更新,页面必须标记为“重启后生效”或提供明确的重启服务操作。
- 路由变更不需要重启,也不需要 reloadSQLite 写入成功后刷新路由快照即可影响新连接。
## HTTP 路由与页面
Admin 前端源码使用 TypeScript 拆分,构建为原生 ES modules。Go 不再 `embed` 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录读取并透传静态响应。运行时 API 前缀通过动态 `config.js` 响应注入。
建议目录:
```text
cmd/gateway/admin.go
cmd/gateway/admin_api.go
cmd/gateway/admin_auth.go
cmd/gateway/admin_db.go
cmd/gateway/admin_static.go
cmd/gateway/admin_frontend/src/
cmd/gateway/admin_static/
index.html
app.css
js/
```
API 路由:
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
| GET | `/admin/app.css` | 公开页面 | 页面样式 |
| GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |
| GET | `/admin/api/me` | 已登录 | 当前用户、角色和权限 |
| GET | `/admin/api/status` | 成员、管理员 | 进程、端口、数据库、服务状态 |
| GET | `/admin/api/routes` | 游客、成员、管理员 | 当前 SQLite 路由列表 |
| PUT | `/admin/api/routes/{host}` | 成员、管理员 | 新增或更新一条路由 |
| DELETE | `/admin/api/routes/{host}` | 成员、管理员 | 删除一条路由 |
| GET | `/admin/api/services` | 成员、管理员 | 服务配置和运行状态 |
| PUT | `/admin/api/services/{name}` | 管理员 | 修改 KCP、QUIC、WebSocket 配置 |
| POST | `/admin/api/services/{name}/restart` | 管理员 | 重启指定服务 |
| GET | `/admin/api/metrics` | 成员、管理员 | 连接和路由统计 |
| GET | `/admin/api/users` | 管理员 | 用户列表 |
| POST | `/admin/api/users` | 管理员 | 新增用户 |
| PATCH | `/admin/api/users/{username}` | 管理员 | 修改用户角色、状态或密码 |
| DELETE | `/admin/api/users/{username}` | 管理员 | 删除用户 |
| GET | `/admin/api/audit-logs` | 管理员 | 审计日志 |
页面形态:
- 用户表为空时只显示初始化管理员界面。
- 未登录且已初始化时只显示登录界面。
- 成员和管理员顶部固定显示 gateway 状态、SQLite 路径和服务状态。
- 路由表支持按 host、upstream、启用状态搜索。
- 游客登录后只展示当前路由表,不展示新增、编辑、删除、服务配置、用户管理入口。
- 新增、编辑、删除路由使用弹窗或行内表单,不单独跳转页面。
- 删除 `default` 路由需要二次确认,因为它是 fallback 路由。
- API 错误直接展示服务端返回的错误信息,便于运维判断问题。
## 用户与权限设计
第一版采用 SQLite 用户表和内存会话,不开放匿名管理 API。这里的“游客”是已登录用户的只读角色不是未登录访问。
角色权限:
| 角色 | 权限 |
| --- | --- |
| 管理员 `admin` | 所有管理能力,包括用户管理、服务配置、路由管理、状态和指标查看 |
| 成员 `member` | 查看状态、指标和服务状态;新增、修改、删除路由 |
| 游客 `guest` | 只能登录、注销、查看自己的信息、查看当前 SQLite 路由 |
认证流程:
- `POST /admin/api/setup` 只在用户表为空时可用,用于创建第一个管理员。
- `POST /admin/api/auth/login` 使用用户名和密码登录。
- 登录成功后服务端生成随机会话 token响应给前端。
- 前端把会话 token 保存在 `sessionStorage`,后续 API 使用 `Authorization: Bearer <session_token>`
- 服务端在内存中保存会话,超过会话有效期后失效;进程重启后所有会话失效。
- 未登录返回 `401`,已登录但权限不足返回 `403`
用户规则:
- 密码只保存哈希,建议使用 bcrypt 或 argon2id不保存明文密码。
- 用户字段至少包含:`username``role``password_hash``disabled``created_at``updated_at`
- 管理员不能删除或禁用最后一个可用管理员账号。
- 修改用户、重置密码、禁用用户都要记录审计日志。
## SQLite 存储设计
建议使用一个 SQLite 数据库保存所有后台状态。优先选择不依赖 CGO 的 SQLite driver降低交叉编译和容器部署成本。
基础表:
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
role TEXT NOT NULL,
password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS routes (
host TEXT PRIMARY KEY,
upstream TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS services (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL,
port INTEGER NOT NULL DEFAULT 0,
options_json TEXT NOT NULL DEFAULT '{}',
restart_required INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
source_ip TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_routes_enabled ON routes(enabled);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
```
服务配置记录:
| name | 默认 enabled | 默认 port | 说明 |
| --- | --- | --- | --- |
| `tcp_admin` | 1 | 25565 | Minecraft TCP 和 Admin 共用入口 |
| `kcp` | 0 | 25566 | KCP 服务 |
| `quic` | 0 | 25565 | QUIC 服务,具体协议参数放在 `options_json` |
| `websocket` | 0 | 25566 | WebSocket 服务path 放在 `options_json` |
注意:
- `tcp_admin` 是基础入口,不允许在后台禁用。
- `tcp_admin.port` 修改后第一版按重启后生效处理。
- KCP、QUIC、WebSocket 配置修改后,如果当前实现无法安全热更新,则标记 `restart_required = 1` 并在页面提示。
- 后续可以把服务运行状态放在内存中,不需要写入 SQLite。
SQLite 运行参数:
- 启动后设置 `PRAGMA journal_mode=WAL`
- 设置合理的 `busy_timeout`,避免后台写入和读取短暂冲突直接失败。
- 所有写操作使用事务。
- 路由和服务配置写入成功后,刷新对应的内存快照。
## 路由存储与运行时读取
路由管理以 SQLite 为唯一持久化来源。
运行时读取:
- 启动时从 SQLite 加载所有 `enabled = 1` 的路由,发布为内存只读快照。
- `mapToHost` 从路由快照读取 host 到 upstream 的映射,不再读取 `config.Hosts`
- 路由 API 写入 SQLite 成功后,重新加载路由快照;不需要 reload。
- 如果快照中没有目标 host则尝试 `default`;没有 `default` 时按现有未命中逻辑关闭连接并记录日志。
写入流程:
1. 校验 host、upstream、enabled 和 note。
2. 在 SQLite 事务中执行 insert、update、delete 或 enable/disable。
3. 事务提交成功后重新加载路由快照。
4. 快照发布成功后返回 API 成功。
5. 如果快照发布失败,返回错误并保留 SQLite 已提交数据;旧路由快照继续服务已有逻辑。
并发与一致性:
- 后台写操作串行执行,避免并发修改同一 host 时互相覆盖。
- 路由快照使用 copy-on-write 发布,转发热路径只做只读 map 查询。
- 没有 `config.toml` 热加载,也不存在配置文件覆盖后台路由的问题。
## 路由校验规则
`host` 校验:
- 不能为空。
- 允许 `default`
- 不允许包含空白字符。
- 不允许包含 `/`,避免和 URL 路由混淆。
`upstream` 校验:
- 不能为空。
- 支持现有前缀:无前缀 TCP、`kcp://``quic://``haproxy://`
- 去掉协议前缀后,必须能解析为 `host:port`
- 不在第一版新增 websocket upstream 前缀,因为当前 README 未列出该前缀的实际实现。
## 指标设计
第一版只做轻量统计,避免影响 TCP 转发热路径。
建议新增全局 `gatewayMetrics`,内部使用 `sync/atomic`
- `total_connections`
- `active_connections`
- `tcp_connections`
- `websocket_connections`
- `route_hits{host}`
- `route_misses`
- `upstream_dial_errors`
实现原则:
- 只在连接进入、退出、路由成功或失败时更新计数。
- 不在每次 `copyForward` 读写时计数。
- route 维度只记录 host 计数,不记录完整客户端 IP。
## 测试计划
单元测试:
- 无配置文件时默认创建 SQLite 并写入默认服务配置。
- 默认启动配置包含 `tcp_admin` enabled 和端口 `25565`
- Admin path、api_path 默认分别为 `/admin/``/admin/api`
- `MC_GATEWAY_TCP_ADMIN_PORT``MC_GATEWAY_ADMIN_PATH``MC_GATEWAY_ADMIN_API_PREFIX` 能覆盖启动期入口配置。
- 启动期环境变量非法时返回明确错误,不创建含错误值的默认服务配置。
- 首次初始化管理员、重复初始化拒绝、管理员登录和禁用用户拒绝登录。
- 登录、注销、会话过期。
- 权限 middleware 的 200、401、403 场景。
- 管理员用户 API 的新增、改角色、重置密码、禁用和删除。
- 不能删除或禁用最后一个可用管理员。
- 路由 API 的新增、修改、删除和校验失败。
- SQLite schema migration、空库初始化、WAL 和 busy timeout 配置。
- 路由写入 SQLite 后能刷新内存路由快照。
- 服务配置 API 能更新 KCP、QUIC、WebSocket 配置并标记是否需要重启。
- 游客可以读取 routes但不能写 routes、配置 services、读取 metrics 或 users。
集成测试:
- 不提供 `config.toml` 时,进程默认通过 `25565` 提供 TCP/Admin 入口。
- 设置 `MC_GATEWAY_TCP_ADMIN_PORT` 和 Admin path/API prefix 后,进程通过指定端口和路径提供后台入口。
- 设置自定义 Admin API prefix 后,登录、状态、路由等 API 都挂载到新的 prefix 下。
- 用户表为空时可访问初始化页面并创建第一个管理员。
- 管理员或成员登录后可访问 `/admin/api/status`
- 游客账号登录后可以通过 `25565` 读取 `/admin/api/routes`
- 成员通过后台新增或修改路由后,新的 Minecraft host 立即按 SQLite 记录转发。
- 同一端口下 Minecraft 握手仍然进入 TCP 分支,并能完成 host 路由。
- WebSocket 启用后,按后台配置的端口和 path 提供服务。
性能验证:
- 管理页面变更后继续运行现有 TCP 转发 benchmark。
- 重点确认路由快照查询和指标计数没有进入已建立连接后的 `copyForward` 热路径。
建议命令:
```bash
go test ./...
go test -race ./...
go test -run TestTcpWebPortReuse ./cmd/gateway
go test -bench 'Benchmark.*tcp' ./cmd/gateway
```
## 实施步骤
1. 移除 `config.toml` 启动依赖,改为无配置默认值启动。
2. 新增 SQLite 打开、schema migration、WAL 和默认服务配置初始化。
3. 增加启动期环境变量解析和校验,覆盖 TCP/Admin 端口、Admin 页面路径和 Admin API 前缀。
4. 将 TCP/Admin 共享 listener 固定为基础入口,默认端口 `25565`,并支持启动期端口覆盖。
5. 把 WebSocket handler 创建逻辑扩展为统一 `newGatewayHTTPHandler()`
6. 增加用户表、首次初始化管理员、密码哈希和会话管理。
7. 增加 Admin 静态页面、初始化页面、登录页面和权限 middleware。
8. 增加 auth、me、users、status、routes、services、metrics、audit-logs API。
9.`mapToHost` 改为读取 SQLite 路由快照,不再读取 `config.Hosts`
10. 实现 routes 写入 SQLite、事务提交和路由快照刷新。
11. 实现 services 写入 SQLite并接入 KCP、QUIC、WebSocket 启动配置。
12. 实现 users 写入 SQLite 和权限校验。
13. 移除或废弃配置文件 watcher 和基于文件的 reload 逻辑。
14. 补充测试和 benchmark 验证。
## 验收标准
- 没有 `config.toml`gateway 默认在 `25565` 启动 TCP 转发和后台管理页面。
- `http://<host>:25565/admin/` 可访问初始化或登录页面。
- 设置 `MC_GATEWAY_TCP_ADMIN_PORT``MC_GATEWAY_ADMIN_PATH``MC_GATEWAY_ADMIN_API_PREFIX` 后,后台入口使用环境变量指定的端口和路径。
- 同一端口下 Minecraft 客户端连接和 host 路由行为正常。
- 用户表为空时只能创建第一个管理员;创建后初始化接口不可再次使用。
- 未登录的管理 API 请求返回 `401`,已登录但权限不足返回 `403`
- 管理员可以新增用户、禁用用户、修改角色和重置密码。
- 管理员可以配置 KCP、QUIC、WebSocket 服务。
- 成员可以查看、搜索、新增、修改、删除 SQLite 路由记录,变更后立即影响新连接路由。
- 游客可以查看和搜索当前 SQLite 路由记录,但看不到写操作入口,直接调用写 API 返回 `403`
- 删除或修改旧 `config.toml` 不影响后台管理中的用户、服务配置和路由。
- SQLite 写入失败或路由快照刷新失败时,页面展示明确错误,旧路由快照仍可继续工作。
- `go test ./...``go test -race ./...` 和 TCP benchmark 完成后无新增失败。

View File

@@ -0,0 +1,493 @@
# 插件开发工具链设计
本文定义插件开发工具链的功能需求和实现边界。目标是让插件作者从新建、开发、测试、打包到发布前检查都使用同一套 `gateway plugin` CLI而不是在每个示例插件里维护重复脚本。
本设计以 [plugin-system-design.md](plugin-system-design.md) 和 [plugin-implementation-plan.md](plugin-implementation-plan.md) 为上游约束。插件作者只维护一个 manifest source 文件,支持 `manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc``manifest.json``.mcgp` 包内仍统一物化为 `manifest.json`。Go 代码中不再维护 `manifestJSON` 或等价重复元数据。
## 目标
- 提供 `gateway plugin init/build/test` 三个核心开发入口。
- 让示例插件和第三方插件使用同一套构建、打包、校验和测试流程。
- 支持 binary `.mcgp` 和 source `.mcgp`,并逐步替代示例插件内的 `build.sh``cmd/render-manifest` 等重复逻辑。
- 保持工具链 runtime-neutralGo plugin 是第一批实现目标,后续 `go-plugin-process``sandbox-process`、WASM 和 ingress service 通过 runtime adapter 扩展。
- 保证 CLI 产物可被 Admin/API 的服务端校验重复验证CLI 只是开发体验和预检工具,不是信任边界。
- 产物尽量稳定可复现:相同输入、相同 builder 和相同环境生成相同 zip 排序、权限和摘要。
## 非目标
- 不引入 `gateway plugin dev ...` 命名空间;开发命令直接扩展在 `gateway plugin` 下。
- 不恢复代码内 manifest 元数据。
- 不支持插件自定义构建脚本作为默认路径。
- 不把 source build 当成 runtime sandbox。
- 不在第一版支持远程插件市场、签名分发或自动升级。
- 不承诺 Go plugin 真正热卸载;本地调试仍遵守运行时限制。
## 设计决策
| 决策 | 结论 |
| --- | --- |
| CLI 命名 | 直接扩展 `gateway plugin init/build/test`,不新增 `dev` 子命名空间 |
| 元数据来源 | 插件目录只允许一个人工维护的 manifest source包内可信元数据统一为 canonical `manifest.json` |
| 打包入口 | `gateway plugin build` 同时承担 build 和 package不再要求插件目录自带 zip 脚本 |
| 示例插件 | `upstream-rewrite``mc-auth-proxy` 迁移到标准 CLI删除重复 `build.sh``render-manifest` 逻辑 |
| runtime 扩展 | CLI 通过 runtime build/test adapter 分发逻辑,命令名不随 runtime 改变 |
| 校验边界 | CLI 校验不能替代 gateway 服务端上传、构建、准入和 enable 校验 |
| source manifest | 源码目录中的 `manifest.yaml/yml/toml/jsonc/json` 是作者输入artifact 包内的 `manifest.json` 是构建时物化结果,不作为第二份人工维护数据 |
## 命令总览
第一版重点实现:
| 命令 | 用途 |
| --- | --- |
| `gateway plugin init <dir>` | 生成插件模板 |
| `gateway plugin build [dir]` | 构建并打包 binary/source `.mcgp` |
| `gateway plugin test [dir]` | 运行插件单元测试和 harness 测试 |
| `gateway plugin validate <path>` | 校验 manifest、源码目录或 `.mcgp` 包 |
| `gateway plugin inspect <artifact.mcgp>` | 查看包内 manifest 和摘要 |
| `gateway plugin compat <artifact.mcgp>` | 检查当前 gateway 对 artifact 的兼容性 |
现有 `gateway plugin source-build <source.mcgp> [out.mcgp]` 保留为兼容命令。后续可以由 `gateway plugin build --from-source <source.mcgp> --out <out.mcgp>` 覆盖同等能力,再把 `source-build` 标记为兼容别名。
所有面向 CI 的命令都应支持:
- `--json`:输出机器可读结果。
- `--quiet`:只输出错误或关键产物路径。
- `--out <path>`:指定产物或报告位置。
- 稳定退出码:参数错误、校验失败、构建失败和测试失败应可区分。
## 完整功能域
工具链最终需要覆盖从插件作者到生产运维的完整闭环。下表是功能需求清单,阶段表示推荐落地顺序,不代表命令只能在该阶段出现。
| 功能域 | 需要解决的问题 | 关键命令 |
| --- | --- | --- |
| 项目脚手架 | 快速生成可构建、可测试、manifest 正确的插件目录 | `init` |
| Manifest 编辑 | 发现字段错误、解释支持能力、避免人工维护环境字段 | `validate``manifest format``manifest explain``features` |
| 构建和打包 | 统一 binary/source `.mcgp` 产物,替代示例脚本 | `build``clean` |
| Source 构建复现 | 在本地或 CI 复现 gateway builder 行为 | `build --from-source` |
| 单元和契约测试 | 在真实上传前验证 SDK、extension point 和 fixture | `test``conformance` |
| 本地安装调试 | 把产物上传到开发 gateway启用、禁用、回滚和查看状态 | `upload``enable``disable``rollback``status` |
| 配置和 secret 预检 | 在启用前验证 config schema、secret ref、reload 兼容性 | `config validate``secret check``preflight` |
| 发布门禁 | 生成能进入 review/CI 的证据 | `preflight``self-test``benchmark` |
| 观测诊断 | 收集插件日志、事件、指标、trace 和诊断包 | `logs``events``metrics``diagnose` |
| 后台任务 | 开发和运维手动触发任务、查看执行状态 | `task list``task run``task cancel` |
| 数据和文件 | 查看 plugin_data/runtime files 配额、导出可迁移数据、GC | `data inspect/export/gc``files inspect/export/gc` |
| Promotion | 跨环境导入导出、diff、drift 和灾备演练 | `export``import``diff``drift``dr-drill` |
| 仓库和供应链 | 导入仓库候选、验证 SBOM/license/signature/advisory | `repo``sbom``sign``verify``advisory` |
| SDK 和契约治理 | 发布前检查 SDK/API/manifest/错误码兼容性 | `contract check``schema export``conformance` |
| Runtime 扩展 | 让新 runtime 复用同一套 init/build/test/validate 命令 | runtime adapter、`runtime features` |
### 命令分层
为了避免第一版实现过大,命令按层交付:
| 层级 | 阶段 | 命令 | 说明 |
| --- | --- | --- | --- |
| 0 | 已有能力 | `inspect``validate``compat``source-validate``source-build` | 当前 CLI 基线,后续保持兼容 |
| 1 | 阶段 1-3 | `init``build``test``features``manifest format/explain` | 插件作者日常开发闭环 |
| 2 | 阶段 4 | `upload``enable``disable``rollback``status``config validate``secret check` | 本地开发 gateway 和 Admin API 操作闭环 |
| 3 | 阶段 5 | `preflight``self-test``benchmark``review status``advisory scan` | 发布治理和准入证据 |
| 4 | 阶段 6 | `logs``events``metrics``diagnose``task``data``files``gc` | 运行诊断、后台任务、数据和资源治理 |
| 5 | 阶段 7-8 | `repo``sbom``sign``verify``contract``conformance``export/import/diff/drift/dr-drill` | 生态、供应链、跨环境发布和未来 runtime |
第一版不必一次实现所有命令,但设计上要避免把能力做进一次性脚本。每个命令都应能输出 JSON 报告,方便 CI 和 Admin API 复用。
## 开发工作流
工具链需要支持这些端到端流程。
### 新插件开发
```sh
gateway plugin init ./my-plugin --id my-plugin --template upstream-dialer --module example.com/my-plugin
cd ./my-plugin
gateway plugin validate .
gateway plugin test .
gateway plugin build . --type both
gateway plugin compat dist/my-plugin.mcgp
```
完成标准:
- 不需要手写 zip 命令。
- 不需要手写 `render-manifest`
- 不需要在 Go 代码中声明 manifest 元数据。
- 默认模板生成 `manifest.yaml`;如需其它格式可使用 `gateway plugin init --manifest-format yaml|toml|jsonc|json`
### 本地调试
```sh
gateway plugin build . --type binary
gateway plugin upload dist/my-plugin.mcgp --gateway http://127.0.0.1:8080
gateway plugin enable my-plugin --config testdata/config.json --profile dev
gateway plugin status my-plugin
gateway plugin logs my-plugin --tail 100
gateway plugin disable my-plugin
```
本地调试命令通过 Admin API 工作,不绕过服务端校验。需要认证时使用现有 Admin session/token 机制CLI 不保存 secret 明文。
### CI 发布检查
```sh
gateway plugin validate .
gateway plugin test . --profile unit,manifest,harness,protocol-smoke
gateway plugin build . --type both --json --out dist/build-report.json
gateway plugin compat dist/my-plugin.mcgp --json --out dist/compat-report.json
gateway plugin preflight dist/my-plugin.mcgp --config config/prod.json --profile prod --json
gateway plugin benchmark dist/my-plugin.mcgp --profile ci-contract --json
```
CI 报告必须能作为 review 证据保存,并包含 artifact sha256、source sha256、SDK/API 版本、runtime、extension points、config hash、测试 profile 和失败原因。
### Source 包复现
```sh
gateway plugin build . --type source
gateway plugin build --from-source dist/my-plugin-source.mcgp --out dist/my-plugin-rebuilt.mcgp
gateway plugin compat dist/my-plugin-rebuilt.mcgp
```
该流程用于验证源码包能被受控 builder 重建,且构建失败不会影响 active artifact。
### 跨环境发布
```sh
gateway plugin export my-plugin --profile staging --out promotion.json
gateway plugin diff promotion.json --target prod
gateway plugin import promotion.json --target prod --dry-run
gateway plugin drift --baseline promotion.json --target prod
```
promotion bundle 默认不包含 secret 明文、secret 密文和 runtime state。缺失 secret mapping、runtime 不兼容、advisory 命中或策略阻断时必须失败。
## `gateway plugin init`
`init` 负责生成一个可直接构建和测试的插件目录。
### 输入
推荐参数:
| 参数 | 说明 |
| --- | --- |
| `--id <id>` | 插件 ID必须满足 manifest 命名规则 |
| `--name <name>` | 展示名,默认由 ID 派生 |
| `--template <name>` | 模板名 |
| `--runtime <type>` | runtime 类型,默认 `go-plugin` |
| `--module <module>` | Go module pathGo runtime 模板必填或由目录推导 |
| `--extension <key>` | 目标 extension point |
第一批模板:
| 模板 | runtime | extension point | 说明 |
| --- | --- | --- | --- |
| `upstream-dialer` | `go-plugin` | `upstream.connect/v1` | 最小 dialer mode 模板 |
| `protocol-proxy` | `go-plugin` | `upstream.connect/v1` | 最小 Minecraft protocol-proxy 模板 |
| `empty-go` | `go-plugin` | 无默认 handler | 用于自定义实验 |
预留模板:
| 模板 | runtime | 说明 |
| --- | --- | --- |
| `wasm-rule` | `wasm` | 未来 rule/config validate 类轻量插件 |
| `sandbox-process` | `sandbox-process` | 未来隔离进程插件 |
| `ingress-service` | `sandbox-process` 或专用 runtime | 未来入口服务插件 |
### 输出目录
Go plugin 模板应至少生成:
- `manifest.yaml`(默认;也支持 `manifest.yml``manifest.toml``manifest.jsonc``manifest.json`
- `go.mod`
- `main.go`
- `main_test.go`
- `README.md`
- `testdata/config.json`
- `testdata/fixtures/`,按模板放置 harness 输入
生成的 manifest source 只包含作者应该维护的字段。`go_version``go_os``go_arch` 等环境相关字段可以为空或使用文档化占位;`build` 时再物化到 artifact manifest。
## `gateway plugin build`
`build` 是统一构建和打包入口。
### 常用模式
| 命令 | 结果 |
| --- | --- |
| `gateway plugin build .` | 默认生成 binary `.mcgp` |
| `gateway plugin build . --type binary` | 生成 binary `.mcgp` |
| `gateway plugin build . --type source` | 生成 source `.mcgp` |
| `gateway plugin build . --type both` | 同时生成 binary 和 source `.mcgp` |
| `gateway plugin build --from-source source.mcgp --out built.mcgp` | 使用 gateway builder 从 source 包生成 binary 包 |
当源码目录内存在多个 `manifest.*` 文件,`build` 必须通过 `--manifest <path>` 显式选择源文件;同一规则也适用于 `test``validate``preflight``self-test``benchmark``manifest format`
推荐默认输出:
- `dist/<plugin-id>.mcgp`
- `dist/<plugin-id>-source.mcgp`
- `dist/<plugin-id>-built.mcgp`
- `dist/build-report.json`
### Manifest 物化规则
源码目录中只能存在一个 manifest source 文件。`build` 读取 `manifest.yaml/yml/toml/jsonc/json` 后在内存中生成 artifact manifest并写入 `.mcgp` 包内的 canonical `manifest.json`
- `artifact_type``--type` 写为 `binary``source`
- binary 包写入 `runtime.entry=plugin.so`
- Go plugin binary 包写入实际 `go_version``go_os``go_arch`
- source 包写入 `build.type=go``build.entry``build.output``build.tags` 和 vendor 策略。
- 构建 provenance、module summary、artifact sha256 等写入 build report 或服务端 build record不要求回写源码目录的 manifest source。
这保证源码仓库里没有第二份需要维护的 manifest也避免 manifest source 与 Go 代码常量不一致。
如果目录中同时存在多个 `manifest.*` 文件CLI 必须失败并要求传入 `--manifest <path>` 显式选择,避免不同格式的 manifest 分叉。`gateway plugin manifest format --canonical-json --type binary|source` 可查看最终写入对应 `.mcgp` 的规范 JSON不传 `--type` 时使用 manifest source 中的 `artifact_type`,缺省按 binary 处理。`--write` 对 YAML/TOML/JSONC 必须保留注释,无法保留时不能覆盖源文件。
### Go Plugin Adapter
第一版 `go-plugin` build adapter 负责:
1. 读取并校验唯一 manifest source或通过 `--manifest` 指定的 manifest source。
2. 运行 `go test ./...`,除非传入 `--skip-tests`
3. 用固定命令构建 `plugin.so``go build -buildmode=plugin -trimpath -buildvcs=false`
4.`go tool nm` 校验 `Plugin` 符号。
5. 生成稳定 zip固定 entry 排序、权限、时间戳策略和路径分隔符。
6. 生成 source `.mcgp` 时只包含允许的源码、`go.mod`、可选 `go.sum/vendor`、README、LICENSE、SBOM 和测试 fixture。
7. 输出 artifact sha256、source sha256、Go/API/SDK 版本和 ABI fingerprint。
第一版不执行包内脚本。未来如果需要复杂构建,应通过受控 builder profile 或外部 CI而不是让插件包携带任意 shell 脚本。
### Runtime Adapter 预留
CLI 内部应抽象 build adapter
```go
type PluginBuildAdapter interface {
RuntimeType() string
ValidateSource(ctx context.Context, req BuildCLIRequest) error
Build(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
PackageSource(ctx context.Context, req BuildCLIRequest) (BuildCLIResult, error)
}
```
预留 runtime 行为:
| runtime | build 产物 | source 包 | 测试方式 |
| --- | --- | --- | --- |
| `go-plugin` | `plugin.so` | Go module source | Go test + extension harness |
| `go-plugin-process` | `plugin.so` 或 host bundle | Go module source | 子进程 host harness |
| `sandbox-process` | executable 或 bundle | 受控源码/二进制 bundle | control RPC harness |
| `wasm` | `plugin.wasm` | WASM source/bundle | WASM host ABI harness |
| `builtin` | 无外部 artifact | 不适用 | gateway 内部测试 |
命令层不应写死 Go plugin 细节。新增 runtime 时只新增 adapter、manifest 校验和 harness不新增一套用户命令。
## `gateway plugin test`
`test` 负责把插件作者的本地测试和 gateway extension contract 连接起来。
### 测试 profile
| Profile | 说明 |
| --- | --- |
| `unit` | 运行插件目录原生测试,例如 `go test ./...` |
| `manifest` | 校验 manifest schema、命名、runtime、extension point 和 config schema |
| `harness` | 运行 extension point fixture |
| `protocol-smoke` | 运行 Minecraft handshake/login smoke fixture |
| `conformance` | 运行当前 gateway 公开契约兼容测试 |
常用命令:
| 命令 | 结果 |
| --- | --- |
| `gateway plugin test .` | 运行模板默认 profile |
| `gateway plugin test . --profile unit,harness` | 运行指定 profile |
| `gateway plugin test . --config testdata/config.json` | 使用指定配置测试 |
| `gateway plugin test . --fixture testdata/fixtures/login-reject.json` | 使用指定 fixture |
| `gateway plugin test dist/plugin.mcgp --profile compat` | 对已打包 artifact 做兼容测试 |
### Harness 范围
第一版 harness 覆盖:
- `upstream.connect/v1` dialer mode匹配 host、返回 `api.ErrPass`、返回自管 conn、错误传播。
- `upstream.connect/v1` protocol-proxy modeinitial data replay、handshake/login packet fixture、disconnect/kick 响应、读写关闭。
- config`ReloadConfig()` 成功、失败、默认值和 schema 校验。
- lifecycle`Init()``Destroy()` 幂等、handler timeout、panic recover。
未来 runtime harness
- `go-plugin-process`:通过 plugin-host 启动插件,验证 drain-only、crash loop 和 control channel。
- `sandbox-process`:验证 capability enforcement、secret handle、filesystem/network policy。
- `wasm`:验证 host ABI、memory/time limit、无授权文件和网络访问。
- `ingress.service/v1`:验证 listener 由 gateway 创建、端口冲突和 disable drain。
## `gateway plugin validate`
`validate` 应支持三类输入:
- manifest source 文件:`manifest.yaml``manifest.yml``manifest.toml``manifest.jsonc``manifest.json`
- 插件源码目录
- `.mcgp` artifact
校验内容:
- manifest schema 和必填字段。
- runtime type、runtime entry、build entry。
- extension point key、type 和 mode。
- config schema JSON。
- secret、event、metric、background task、external dependency、data store 和 file store 命名。
- binary/source 包结构、zip slip、大小限制和允许文件。
- 当前 gateway feature support。
对于源码目录,`validate` 不能执行插件代码最多做静态文件、manifest 和包结构检查。需要运行代码的检查放在 `test``build`
## 本地 Admin 操作命令
阶段 4 后CLI 应能操作开发或测试环境的 Admin API形成不依赖页面的调试闭环。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin upload <artifact.mcgp>` | 上传 artifact/source package返回 artifact ID、sha256 和校验摘要 |
| `gateway plugin status [plugin-id]` | 展示 desired/runtime state、active/desired/loaded artifact、recent error 和 restart required |
| `gateway plugin enable <plugin-id>` | 设置 desired enabled支持 `--artifact``--config``--profile``--priority` |
| `gateway plugin disable <plugin-id>` | 设置 desired disabledprotocol-proxy 连接按策略 drain 或 force close |
| `gateway plugin delete <plugin-id>` | 删除 desired state 或 artifact支持保留/删除数据选项 |
| `gateway plugin rollback <plugin-id>` | 回滚 artifact 或 config snapshot并重新执行当前基础门禁 |
| `gateway plugin config validate <plugin-id>` | 校验 config JSON、schema、secret ref 和 `ReloadConfig()` dry-run |
| `gateway plugin secret check <plugin-id>` | 检查 manifest 必需 secret、secret ref、版本和 reload/rotation 状态 |
这些命令必须通过 Admin API 执行并复用服务端权限、审计和错误码。CLI 不直接写 SQLite不直接操作 artifact store也不能绕过上传时的 zip/manifest 校验。
## 发布治理命令
阶段 5 后CLI 需要生成和读取生产准入证据。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin preflight` | 运行 config、secret、feature、runtime limits、scope/rollout、conflict 和 Minecraft capability 检查 |
| `gateway plugin self-test` | 运行插件实现的 quick/protocol-smoke/integration profile保存脱敏证据 |
| `gateway plugin benchmark` | 记录或执行 benchmark profile输出 P95/P99、error rate、capacity 和 baseline diff |
| `gateway plugin review status` | 查看当前 artifact/config/scope/risk/policy hash 是否已有有效 review |
| `gateway plugin advisory scan` | 按 artifact sha256、plugin/version、SBOM dependency 或 source metadata 扫描安全公告 |
发布治理命令的 JSON 报告必须包含稳定 `code``severity``message``evidence_id` 和相关 hash不能要求 CI 解析人类可读文本。
## 观测和运维命令
阶段 6 后CLI 应覆盖插件出问题时的定位、证据导出和资源清理。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin logs <plugin-id>` | 查看插件日志摘要,支持 tail、时间范围、trace ID 和脱敏 |
| `gateway plugin events <plugin-id>` | 查看插件业务事件、drop/dead-letter 摘要和 replay/drop 操作 |
| `gateway plugin metrics <plugin-id>` | 查看 handler calls、duration、panic、timeout、active proxy connections 和 custom metrics |
| `gateway plugin diagnose <plugin-id>` | 生成诊断包,包含 manifest、state、recent logs/events/metrics/build summary不含 secret 明文 |
| `gateway plugin task list/run/cancel <plugin-id>` | 查看、手动触发或取消 background task |
| `gateway plugin data inspect/export/gc <plugin-id>` | 查看 plugin_data schema/data class/quota导出可迁移数据执行 dry-run 或清理 |
| `gateway plugin files inspect/export/gc <plugin-id>` | 查看 runtime files/resources/cache/tmp/log/diagnostic 用量和 GC candidate |
| `gateway plugin gc --dry-run` | 汇总 artifact、build log、diagnostic、plugin_data 和 runtime files 的可清理对象 |
所有清理命令默认 dry-run实际删除必须显式传入确认参数并写审计。数据导出只允许 manifest 声明 `exportable=true` 且调用者有权限的数据。
## 仓库、供应链和签名命令
阶段 8 的分发能力不能绕过本地 review 和 enable 流程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin repo list/search/show` | 查看 official/internal/file/url repository 中的候选版本 |
| `gateway plugin repo import` | 下载或导入候选 artifact 到本地 store只生成 local artifact不自动启用 |
| `gateway plugin sbom generate/verify` | 生成或验证 SBOM供 advisory/license 策略使用 |
| `gateway plugin sign` | 对 artifact 或 promotion bundle 签名,未来能力 |
| `gateway plugin verify` | 验证 signature、sha256、SBOM、license 和 provenance |
| `gateway plugin advisory import/scan/ack` | 导入安全公告、重新扫描本地 artifact、记录 mitigation/ack |
仓库删除、远端更新或签名失败都不能自动改变本地 active artifact。repository import 之后仍要走 validate、compat、preflight、review 和 enable。
## 契约和 SDK 命令
插件系统公开 API 后CLI 还要服务 gateway release 过程。
| 命令 | 职责 |
| --- | --- |
| `gateway plugin features` | 输出当前 gateway 支持的 runtime、extension point、manifest field、feature key 和版本 |
| `gateway plugin schema export` | 导出 manifest JSON schema、config UI hint schema 和 extension fixture schema |
| `gateway plugin contract check` | 对比上一 release 的 SDK/API/manifest/error code/CLI JSON 输出兼容性 |
| `gateway plugin conformance` | 构建示例插件,运行 source/binary fixture 和 Admin/CLI golden test |
`features` 输出必须和 Admin API 使用同一契约。`contract check``conformance` 失败应被视为 gateway release 风险,不是普通文档错误。
## Runtime 扩展命令
新增 runtime 不应增加一套平行 CLI。`init/build/test/validate/compat/preflight` 必须根据 `manifest.runtime.type` 选择 adapter。
| runtime | 额外 CLI 需求 |
| --- | --- |
| `go-plugin-process` | `test` 能启动 plugin-host harness`preflight` 检查 migration mode、safe point、drain-only/fd-live 声明 |
| `sandbox-process` | `validate/preflight` 检查 capability、secret handle、filesystem/network/env/cpu/memory policy`test` 验证 control RPC 和 crash loop |
| `wasm` | `build` 生成 `plugin.wasm``test` 使用 WASM host ABI`preflight` 检查 memory/time/no file/no network |
| `ingress.service/v1` | `preflight` 检查 listener ownership、port conflict、TLS/secret refs 和 disable drain |
| build-time instrumentation | 不进入 runtime plugin enable/disableCLI 只提供 manifest/provenance/conformance/benchmark/smoke 证据 |
如果目标 gateway 不支持某 runtime`compat``preflight` 必须返回明确的 blocking code而不是降级为 Go plugin 尝试加载。
## 发布前检查
发布前推荐流程:
1. `gateway plugin validate .`
2. `gateway plugin test . --profile unit,manifest,harness`
3. `gateway plugin build . --type both`
4. `gateway plugin validate dist/<plugin-id>.mcgp`
5. `gateway plugin compat dist/<plugin-id>.mcgp`
6. 可选:`gateway plugin build --from-source dist/<plugin-id>-source.mcgp --out dist/<plugin-id>-rebuilt.mcgp`
7. 可选:`gateway plugin test dist/<plugin-id>.mcgp --profile conformance`
CI 产物应至少保存:
- binary `.mcgp`
- source `.mcgp`
- build report JSON
- test report JSON
- artifact sha256 和 source sha256
## 示例插件迁移
`examples/plugins/upstream-rewrite``examples/plugins/mc-auth-proxy` 迁移目标:
- README 使用 `gateway plugin build . --type both`
- README 使用 `gateway plugin test .`
- 删除或降级 `build.sh` 为兼容包装;最终不再作为主路径。
- 删除 `cmd/render-manifest`,由 CLI 根据源码 manifest source 生成 artifact manifest。
- 示例插件的测试 fixture 进入 `testdata/fixtures/`
- 示例插件进入 conformance suite构建失败视为插件 API 回归。
迁移时必须保留现有 `.mcgp` 格式binary 包仍包含 `manifest.json``plugin.so`source 包仍包含 `manifest.json``go.mod`、build entry 和源码。
## 实现顺序
建议按以下顺序实现:
1. 增加 `gateway plugin init`,生成 `upstream-dialer``protocol-proxy` Go 模板。
2. 增加 `gateway plugin build` 的 Go plugin binary/source 打包能力,复用现有 artifact 校验逻辑。
3.`gateway plugin build` 替换示例插件 `build.sh``cmd/render-manifest` 主路径。
4. 增加 `gateway plugin test` 的 unit、manifest 和 upstream harness profile。
5.`source-build` 能力收敛为 `build --from-source`,保留兼容别名。
6. 增加 runtime build/test adapter 接口,为 `go-plugin-process``sandbox-process` 和 WASM 实现预留扩展点。
7. 增加 JSON report、conformance profile 和 CI golden 输出。
每一步结束时,现有 `inspect/validate/compat/source-validate/source-build` 不能回归。
## 验收标准
- 新建 `upstream-dialer` 模板后,不手写额外脚本即可 build/test/validate。
- 新建 `protocol-proxy` 模板后,能跑通 Minecraft handshake/login smoke fixture。
- `upstream-rewrite``mc-auth-proxy` 示例插件使用标准 CLI 生成 binary/source `.mcgp`
- 生成的 `.mcgp` 能通过现有上传和服务端校验。
- manifest source 与 Go 代码不重复维护插件元数据。
- Go plugin adapter 之外的 runtime 可以通过 adapter 注册进入同一套 `init/build/test` 命令。
- CLI 失败输出能定位到字段、文件或 fixture而不是只返回通用错误。

View File

@@ -0,0 +1,88 @@
# 插件系统阶段实现计划
本文以 [plugin-system-design.md](plugin-system-design.md) 作为最终目标设计文档,把插件系统拆分成多个可上线的实现阶段。每个阶段都必须在结束时保持 gateway 当前可用:可以启动、可以回滚、可以排障,且不会要求后续阶段补齐后才能恢复基本能力。
插件开发工具链作为跨阶段交付项单独设计,见 [plugin-development-toolchain-design.md](plugin-development-toolchain-design.md)。工具链主入口为 `gateway plugin init/build/test`,并需要从第一批 Go plugin 示例开始预留未来 runtime adapter。
## 拆分原则
- 以可用的纵向切片拆分而不是按数据库、API、UI、SDK 等横向模块拆分。
- 每个阶段都必须有明确的启用路径、失败回退路径和最小运维证据。
- 默认运行路径保持保守:先稳定 `go-plugin + upstream.connect/v1`,再增加源码包、治理、观测和未来 runtime。
- 未来能力必须可关闭、可灰度或只做设计预留,不能破坏前一阶段的可用状态。
- `plugin-system-design.md` 是目标状态;阶段文档只描述实现顺序和阶段边界。
## 阶段总览
| 阶段 | 文档 | 阶段结束时可用状态 |
| --- | --- | --- |
| 1 | [Managed Binary Plugin MVP](plugin-implementation-stages/phase-01-managed-binary-mvp.md) | 管理员可以上传二进制 `.mcgp`,通过 Admin API/CLI 加载、启用、禁用、删除可信插件;`upstream.connect/v1` dialer mode 可用 |
| 2 | [Protocol Proxy MVP](plugin-implementation-stages/phase-02-protocol-proxy-mvp.md) | `upstream.connect/v1` protocol-proxy mode 可用,插件可以接管 MC 字节流并实现登录代理示例 |
| 3 | [Source Package Builder](plugin-implementation-stages/phase-03-source-package-builder.md) | 管理员可以上传 source `.mcgp`,受控 builder 产出可加载 artifact构建失败不影响当前插件 |
| 4 | [Admin UI, Config, Secret, Rollback](plugin-implementation-stages/phase-04-admin-ui-config-secret-rollback.md) | 管理页具备可操作的插件管理闭环,支持配置 schema、secret、配置快照和回滚 |
| 5 | [Governance And Release Gates](plugin-implementation-stages/phase-05-governance-release-gates.md) | 生产启用前有准入策略、review、冲突分析、preflight/self-test、性能门禁和安全公告处理 |
| 6 | [Observability And Operations](plugin-implementation-stages/phase-06-observability-operations.md) | 插件 metrics、events、trace、日志、诊断、background task、plugin_data 和文件资源治理可用 |
| 7 | [Extension Ecosystem](plugin-implementation-stages/phase-07-extension-ecosystem.md) | 在稳定主路径上增加 route/status/provider/event/rule/Admin auth 等扩展点和官方插件能力 |
| 8 | [Future Runtimes And Distribution](plugin-implementation-stages/phase-08-future-runtimes-distribution.md) | 可选引入 `go-plugin-process`、sandbox/WASM、ingress service、仓库、签名和构建期增强默认路径仍可运行 |
## 功能到阶段映射
下表用于确认 [plugin-system-design.md](plugin-system-design.md) 中的目标能力已经拆入某个阶段。一个能力可能在早期阶段先实现最小可用版本后续阶段再补齐治理、UI 或生态扩展。
| 目标能力 | 阶段 | 拆分说明 |
| --- | --- | --- |
| `.mcgp` binary artifact、manifest 静态校验、artifact 登记 | 1 | 先支持二进制可信 Go plugin上传不执行代码 |
| Plugin Manager、desired/runtime state、dispatch table、审计 | 1 | 建立正式管理路径,替代探索式 config 插件入口 |
| `upstream.connect/v1` dialer mode | 1 | 第一条可用数据路径,覆盖 upstream rewrite、自定义拨号 |
| `upstream.connect/v1` protocol-proxy mode 和 `net.Conn` 接管 | 2 | 支持完整 MC 字节流接管、initial data replay、draining |
| MC 正版/三方登录插件、forwarding、登录后协议处理 | 2 | 由 protocol-proxy 插件实现core 不消费认证结果 |
| Minecraft capability manifest、protocol smoke fixture | 2 | 支撑管理页展示和后续发布门禁 |
| source `.mcgp`、builder、构建 provenance | 3 | 源码包构建成 `plugin.so` 后复用阶段 1/2 加载路径 |
| builder 隔离、Go/module/ABI 记录、source/build log GC | 3 | 构建失败不影响 active artifact |
| `gateway plugin init/build/test` 开发工具链 | 1-3后续扩展 | 阶段 1/2 提供 Go plugin 模板和 harness阶段 3 收敛 source/binary 打包;后续 runtime 通过 adapter 接入 |
| Admin 页面基础管理闭环 | 4 | 上传、构建状态、加载、启用、禁用、删除、回滚 |
| 配置 schema、配置快照、配置迁移入口 | 4 | 错误配置不切换 active artifact |
| SecretStore、secret version、reload/rotation 基础 | 4 | secret 不在页面、日志、审计中明文展示 |
| artifact rollback、config rollback | 4 | 回滚前重新执行当前基础门禁 |
| admission policy、review、risk、warning override | 5 | 生产启用前可解释和可审计 |
| composition conflict、scope overlap、dispatch plan | 5 | 阻断 protocol-proxy 重叠、provider 单例冲突等 |
| preflight/self-test、benchmark release gate | 5 | 高风险插件启用前有证据 |
| denylist、quarantine、revoke、安全公告 | 5 | 阻断受影响 artifact 的 enable/rollback |
| metrics、custom metrics、business events、trace | 6 | 提供运行时观测和脱敏摘要 |
| plugin logger、diagnostic package、Runbook 支撑 | 6 | 插件故障可定位、可降级、可导出摘要 |
| background task、ExternalClient、外部依赖治理 | 6 | 周期同步、受控外联、熔断和健康状态 |
| PluginDataStore、PluginFileStore、runtime file GC | 6 | 插件私有数据和文件资源受配额/retention 管理 |
| route resolver/provider、route decision | 7 | 降低动态路由和外部 CMDB 集成成本 |
| status ping、MOTD、维护模式 | 7 | 不必完整 protocol-proxy 即可定制状态响应 |
| middleware、provider、event subscriber、rule/policy engine | 7 | 补齐 Hook 之外的生产扩展形态 |
| Admin auth provider、外部身份绑定 | 7 | 只影响管理页登录,保留本地 break-glass |
| `go-plugin-process` 服务启动模式、进程级卸载、fd/shm 迁移 | 8 | 未来可选,默认 `in-process` 路径仍可运行 |
| sandbox-process、WASM、capability enforcement | 8 | 面向隔离、跨语言和轻量规则场景 |
| `ingress.service/v1` 自定义入口服务 | 8 | 由 gateway/supervisor 管理 listener不允许插件任意监听 |
| 插件仓库、签名、SBOM 漏洞扫描、license policy | 8 | 仓库只导入本地 artifact不自动启用 |
| build-time instrumentation | 8 | 官方/组织 CI 能力,产物是 gateway binary不是热加载插件 |
| promotion、drift、DR drill | 4-6 | 阶段 4 建立回滚和快照,阶段 5/6 补齐门禁、diff、诊断和演练证据 |
## 全阶段不变量
这些规则从阶段 1 开始就不能被破坏:
- 上传包校验不能执行插件代码。
- 生产路径统一以 `.mcgp` artifact 为单位管理。
- 插件管理写操作必须有审计日志。
- 启用失败不能破坏旧 dispatch table。
- 禁用插件后,新连接不能再进入该插件。
- 已加载 Go plugin 不能承诺真正热卸载;只能逻辑禁用或未来通过 `go-plugin-process` 退出子进程回收。
- MC 正版/三方登录、身份映射、forwarding 和后续协议处理属于 protocol-proxy 插件,不由 gateway core 拼装。
- 玩家名、UUID、source IP、secret、token、session response 和 packet payload 默认不进入指标标签、审计明文或普通诊断输出。
## 阶段推进规则
进入下一阶段前必须满足:
- 当前阶段文档中的验收项全部通过。
- 已实现能力有最小自动化测试或可重复手动验证步骤。
- 失败路径已验证:加载失败、启用失败、禁用、删除、重启恢复。
- 文档已更新:用户怎么启用、怎么回滚、怎么排障。
如果某阶段出现实现复杂度超出预期,允许拆出子阶段,但子阶段也必须保持“当前可用”。

View File

@@ -0,0 +1,125 @@
# 阶段 1Managed Binary Plugin MVP
## 目标
交付最小可用的受管理插件系统:管理员可以上传二进制 `.mcgp`gateway 能校验、登记、加载、启用、禁用和删除可信 Go plugin。第一阶段只要求 `upstream.connect/v1` 的 dialer mode 可用,用于替换上游拨号或实现简单 upstream rewrite。
本阶段完成后,插件系统已经从探索代码进入 SQLite/Admin 管理路径,但不承诺源码包构建、完整 protocol-proxy、复杂治理和 Admin 完整页面。
## 可用性检查点
阶段结束时必须能做到:
- gateway 无插件时行为不变。
- 管理员上传一个二进制 `.mcgp` 后,可以通过 Admin API 或 CLI inspect artifact。
- 管理员可以加载并启用 `upstream-rewrite` 示例插件。
- 命中插件 scope 的连接走插件返回的 upstream conn不命中时走原默认 upstream。
- 禁用插件后,新连接不再调用该插件。
- 插件启用失败或 handler panic 不破坏旧 dispatch table。
- 重启后SQLite 中 enabled 的插件按 priority 恢复。
## 范围
### 包和 artifact
- 支持 `.mcgp` zip 上传。
- `artifact_type=binary`
- `runtime.type=go-plugin`
- 包内必须包含 `manifest.json``plugin.so`
- 上传阶段只解析 zip 和 manifest不执行插件代码。
- 记录 artifact sha256、plugin ID、version、Go version、GOOS/GOARCH、API version、extension points 和 capabilities 摘要。
### 数据模型
实现最小表:
- `plugin_artifacts`
- `plugins`
- `plugin_operations`
- `plugin_config_snapshots`
- `audit_logs.metadata_json` 扩展或等价结构化审计字段
字段必须能表达:
- artifact 状态uploaded、validated、loadable、loaded、rejected、deleted。
- plugin desired stateenabled、disabled、deleted。
- runtime statenot_loaded、loaded、enabled、failed、disabled。
- desired generation 和 applied generation。
- active artifact、desired artifact、loaded artifact 的差异。
### Runtime 和 dispatch
- 新增 Plugin Manager。
- 保留现有 `api.Plugin``Gateway.Hook` 兼容层。
- 将现有 `HookUpstream` 收敛为 `upstream.connect/v1` 注册路径。
- dispatch table 使用只读快照,更新时整体替换。
- handler 排序规则priority 升序priority 相同按 plugin ID。
- handler 返回 `ErrPass` 时继续后续 handler返回 `net.Conn` 时停止;返回普通 error 时本次连接失败。
- handler 调用必须有 panic recover、timeout 和错误计数。
### Admin API / CLI
最小接口:
- 上传 artifact。
- 查看 artifact。
- 创建或更新 plugin desired state。
- load。
- enable。
- disable。
- delete。
- 查看 plugin runtime state。
- 查看 dispatch plan 摘要。
CLI 可以先作为开发工具,覆盖:
- `plugin inspect`
- `plugin validate`
- `plugin compat`
### 示例
提供 `examples/plugins/upstream-rewrite`
- 读取 `match_host``upstream` 配置。
- 注册 `upstream.connect/v1`
- 命中时 `net.Dial` 到 upstream 并返回连接。
- 不命中时返回 pass。
## 明确不做
- 不支持 source `.mcgp` 构建。
- 不支持 protocol-proxy mode。
- 不支持 SecretStore。
- 不支持完整 Admin 页面。
- 不支持准入 review、SBOM、license 策略和仓库。
- 不支持真正热卸载。
- 不支持 sandbox、WASM 或 `go-plugin-process`
## 实现任务
1. 增加 `.mcgp` 静态校验zip slip、大小、manifest、runtime entry。
2. 增加 manifest schema v1 的最小字段校验。
3. 增加 Plugin Manager 和 runtime adapter 抽象,只实现 `go-plugin`
4. 增加 SQLite migration。
5. 增加 desired state reconcile。
6. 把连接路径接入 dispatch table snapshot。
7. 实现 `upstream.connect/v1` dialer mode contract。
8. 实现 load/enable/disable/delete API。
9. 增加基础审计事件。
10. 增加 upstream-rewrite 示例插件。
## 验收
- `upstream-rewrite` 能通过 `.mcgp` 上传、load、enable。
- 启用后指定 host 连接到新 upstream。
- disable 后新连接恢复默认 upstream。
- plugin.Open 失败返回稳定错误,不影响其他插件和默认连接路径。
- gateway 重启后 enabled 插件恢复。
- `git diff --check` 和现有测试通过。
## 回滚策略
- 删除或 disable 插件即可恢复默认 upstream。
- 如果 artifact 已加载delete 后标记 pending cleanup提示重启彻底清理。
- 如果 Plugin Manager 初始化失败gateway 应可在禁用插件系统配置下启动,并保留原有路由能力。

View File

@@ -0,0 +1,108 @@
# 阶段 2Protocol Proxy MVP
## 目标
在阶段 1 的管理和生命周期基础上,让 `upstream.connect/v1` 支持 protocol-proxy mode。插件可以返回自管 `net.Conn`gateway 将已读取的 initial handshake bytes 回放给该连接,并把后续客户端字节转发给插件 endpoint。
本阶段使 MC 正版/三方登录插件具备技术可行性登录、身份映射、forwarding 和登录后的协议处理都由插件完成gateway core 只负责连接交接和治理。
阶段 2 实现后gateway core 不解析 login/encryption/session不消费插件内部认证结果也不根据玩家名、UUID、权限或 session 状态改变后续路由。protocol-proxy 插件接管连接后Minecraft 登录业务完全属于插件core 只保留 initial data replay、双向 copy、draining、force close 和低基数运行摘要。
## 可用性检查点
阶段结束时必须能做到:
- dialer mode 仍可用。
- protocol-proxy 插件可以接管完整 MC 字节流。
- 初始 handshake 不丢失、不重复。
- protocol-proxy 插件禁用后,新连接不再进入插件;已有连接进入 draining 或按管理员操作 force close。
- `mc-auth-proxy` 示例至少能跑通一个登录失败响应或简单 session fixture。
## 范围
### `net.Conn` 接管契约
实现:
- `UpstreamConnectRequest.InitialData` 复制语义。
- returned conn 初始写入 deadline。
- 初始写入失败后的关闭和错误记录。
- 双向 copy、half-close 退化、字节数统计、耗时统计。
- active proxy connection 计数。
- protocol-proxy 连接 draining 状态。
### 请求字段
补齐 request 字段:
- connection ID。
- trace ID。
- source addr。
- normalized server host 和 raw server host。
- protocol version。
- next state。
- route ID、route tags、upstream raw/protocol/address。
- transport、service name、listener port。
字段新增必须只追加,不改变阶段 1 语义。
### 示例插件
提供 `examples/plugins/mc-auth-proxy` 初版:
- 注册 `upstream.connect/v1`
- 使用 `net.Pipe` 或等价 endpoint。
- 读取 handshake/login start。
- 对不支持或 fixture 失败场景返回 login disconnect/kick。
- 连接 backend 并做最小透明转发。
- 通过事件或日志上报低基数失败原因。
本阶段不要求完整生产级 Mojang/Yggdrasil 实现,但示例结构必须能承载后续认证源。
### Minecraft 能力声明
manifest 支持 `minecraft` 字段:
- protocol versions。
- states。
- auth modes。
- forwarding supported/default。
- unsupported policy。
- modded 声明。
Admin API 可以先展示摘要,不要求完整 UI。
## 明确不做
- 不让 gateway core 解析 login/encryption/session。
- 不让插件返回 `AuthResult` 给 core。
- 不实现 `auth.provider/v1`
- 不做 play 阶段 packet filter。
- 不做真实客户端大规模压测门禁。
## 实现任务
1. 定义 `UpstreamConnectRequest` 稳定 struct。
2. 增加 `ErrPass``ErrBlocked` 和普通 error 行为。
3. 实现 initial data replay。
4. 实现 protocol-proxy connection lifecycle。
5. 实现 draining 和 force close API。
6. 增加 protocol-proxy metrics。
7. 增加 Minecraft capability manifest schema。
8. 增加 mc-auth-proxy 示例。
9. 增加 protocol smoke test helper。
## 验收
- protocol-proxy 示例能读取 gateway 已解析前的完整 handshake bytes。
- 初始包只被插件处理一次。
- 插件返回不可读 conn 时,连接路径不会永久阻塞。
- 插件 panic 只影响当前连接。
- disable 后新连接不再进入插件。
- 文档明确 MC 登录业务完全属于插件。
## 回滚策略
- disable protocol-proxy 插件恢复默认 upstream。
- 对已有 protocol-proxy 连接,默认 drain必要时 force close。
- 如果 protocol-proxy 功能引发问题,可以保留阶段 1 dialer mode 插件能力。

View File

@@ -0,0 +1,112 @@
# 阶段 3Source Package Builder
## 目标
支持 source `.mcgp`。管理员可以上传源码包,由受控 builder 构建出最终 `plugin.so` artifact再进入阶段 1/2 已经可用的加载和启用流程。
本阶段解决开发者分发源码包、记录构建环境和产物 provenance 的问题。构建失败不得影响当前 active 插件。
## 可用性检查点
阶段结束时必须能做到:
- 上传 binary `.mcgp` 的路径不受影响。
- 上传 source `.mcgp` 后创建 build job。
- build 成功后生成新的 binary artifact可 load/enable。
- build 失败只记录错误和日志摘要,不改变当前 active artifact。
- 管理员能看到 source sha256、builder、Go version、module list 和 artifact sha256。
## 范围
### Source 包格式
source `.mcgp` 必须包含:
- `manifest.json`
- `go.mod`
- build entry
- 源码文件
可选:
- `go.sum`
- `vendor/`
- README、LICENSE、SBOM
不执行包内任意 shell 脚本。构建命令由 gateway/builder 固定生成。
### Builder
支持两种 builder
- `local-process`:开发模式。
- `container`:生产推荐。
生产默认推荐 container builder 或外部 CI。gateway 主进程不得直接执行 `go build`
固定构建维度:
- Go version。
- GOOS/GOARCH/GOAMD64/GOARM64。
- CGO。
- build tags。
- SDK module version。
- GOPROXY/GONOSUMDB/GOPRIVATE 策略。
- vendor required。
### Provenance
记录:
- source package sha256。
- artifact sha256。
- builder type/image/version。
- Go version。
- `go list -m -json all` 摘要。
- `go version -m` 摘要。
- ABI fingerprint。
- build log 摘要。
- build start/end/duration。
### GC
实现:
- source package 保留策略。
- build log 保留策略。
- artifact GC candidate。
- active/desired/snapshot referenced artifact 不可被 GC。
## 明确不做
- 不把源码构建等同于运行时沙箱。
- 不支持自定义构建脚本。
- 不强制签名。
- 不实现远程插件仓库。
## 实现任务
1. 扩展 `.mcgp` 校验支持 `artifact_type=source`
2. 增加 `plugin_builds` 状态机。
3. 实现 build operation 和取消/重试。
4. 实现 local-process builder。
5. 实现 container builder 接口或预留适配。
6. 构建后执行 manifest ABI 校验。
7. 构建成功写入 `plugin_artifacts`
8. 构建失败保存脱敏日志摘要。
9. 增加 build API/CLI。
10. 更新示例插件,支持 source package。
## 验收
- source upstream-rewrite 能构建并启用。
- source mc-auth-proxy 能构建或至少通过编译 fixture。
- builder Go version 不匹配时阻断启用或构建。
- 构建日志不包含 secret、环境 token 或完整私有路径。
- 构建失败不影响 active artifact。
## 回滚策略
- 构建产物只有 enable 后才影响流量。
- 构建失败或产物校验失败时保留旧 artifact。
- 如 builder 配置异常,可关闭 source package 构建,继续支持 binary `.mcgp`

View File

@@ -0,0 +1,110 @@
# 阶段 4Admin UI、配置、Secret 和回滚
## 目标
把阶段 1 到 3 的能力做成管理员可用的管理闭环。管理页支持上传、构建状态、加载、启用、禁用、删除、配置编辑、secret 配置、配置快照和 artifact 回滚。
阶段结束时,普通运维不需要直接调用底层 API 才能完成插件日常管理。
## 可用性检查点
阶段结束时必须能做到:
- 管理员能在页面看到插件列表、artifact、runtime state 和最近错误。
- 管理员能上传 binary/source `.mcgp`
- 管理员能编辑配置并执行 dry-run 校验。
- 管理员能配置插件 secret ref不看到 secret 明文。
- 管理员能禁用、删除、切换 artifact 和回滚配置快照。
- 配置错误不会切换 active artifact。
## 范围
### Admin 页面
列表展示:
- plugin ID、name、version。
- artifact type。
- runtime state。
- desired state。
- active/desired/loaded artifact。
- extension points。
- priority。
- scope/rollout。
- restart required。
- health/最近错误。
详情页展示:
- manifest metadata。
- Go/API/ABI 兼容信息。
- capabilities 摘要。
- Minecraft capability 摘要。
- build 历史和日志摘要。
- current config。
- secret 状态。
- dispatch plan。
- active proxy connections。
### 配置
- 支持 JSON 编辑器兜底。
- 支持 JSON Schema 基础校验。
- 支持 `ReloadConfig()` dry-run。
- 支持 sensitive 字段脱敏 diff。
- 支持 config snapshot。
- 支持 config-only rollback 和 full desired rollback。
### Secret
实现最小 SecretStore
- 创建/更新 secret。
- secret ref 校验。
- 当前/previous version。
- reload required/hot reload 标记。
- secret 不进入日志、审计明文和 API 响应。
### 回滚
支持:
- artifact rollback。
- config snapshot rollback。
- rollback 前重新执行兼容性和当前基础门禁。
- rollback 写审计。
## 明确不做
- 不做完整准入审批。
- 不做 SBOM/license 阻断。
- 不做外部 KMS。
- 不做复杂声明式 UI自定义 HTML/JS 不支持。
- 不做 promotion bundle。
## 实现任务
1. 实现插件列表和详情页。
2. 实现上传和构建状态 UI。
3. 实现配置编辑、schema 校验和 dry-run。
4. 实现 secret 状态和编辑流程。
5. 实现 artifact rollback UI/API。
6. 实现 config snapshot diff/rollback。
7. 实现 restart required 展示。
8. 实现基础 permission key 映射到 admin/member/guest。
9. 所有写操作写审计。
## 验收
- 管理员可在 UI 上传并启用 upstream-rewrite。
- 管理员可在 UI 上传 source package 并查看 build result。
- 修改错误配置不会影响当前运行插件。
- secret 在页面和审计里不显示明文。
- rollback 到旧 artifact 后新连接使用旧版本。
- member 只能查看状态,不能执行写操作。
## 回滚策略
- UI 出问题时保留 Admin API/CLI 操作路径。
- 配置保存失败不改变 desired generation。
- 回滚失败不改变当前 active state。

View File

@@ -0,0 +1,116 @@
# 阶段 5Governance And Release Gates
## 目标
把插件从“能运行”提升到“可安全进入生产”。本阶段实现准入策略、review、风险分级、冲突分析、发布门禁、preflight/self-test、benchmark 结果和安全公告响应。
阶段结束后,管理员可以解释一个插件为什么能启用、为什么被阻断、启用会影响哪些流量,以及如何回滚。
## 可用性检查点
阶段结束时必须能做到:
- 高风险插件启用前需要 review。
- protocol-proxy scope 重叠会阻断启用。
- 必需 secret、依赖、feature 缺失会阻断启用。
- preflight/self-test 失败会阻断或进入 warning。
- benchmark 结果超过阈值会进入 warning/blocking。
- denylist/advisory 命中后不能 rollback 到受影响 artifact。
## 范围
### 准入策略
实现:
- dev/staging/prod profile。
- risk level。
- policy snapshot hash。
- warning override TTL。
- review 记录绑定 artifact/config/scope/rollout/runtime limits/features/policy hash。
- denylist、quarantine、revoke。
### 冲突分析
实现:
- scope overlap。
- protocol-proxy singleton 冲突。
- provider singleton 冲突。
- middleware ordering cycle。
- shadowed handler warning。
- dispatch plan API/UI。
### Preflight 和 SelfTest
实现通用门禁:
- config。
- secret。
- external dependency 声明。
- runtime limits。
- scope/rollout。
- Minecraft capability。
- backend forwarding warning。
插件实现 `PreflightChecker``SelfTester` 时复用结果。
### 性能门禁
记录:
- benchmark profile。
- P95/P99。
- error rate。
- active proxy capacity。
- baseline diff。
默认策略:
- 退化超过 20% warning。
- 退化超过 50% 或超过 runtime limit blocking。
### 安全公告
支持本地 advisory
- artifact sha256 match。
- plugin/version range match。
- SBOM dependency match。
- recommended action。
- fixed version。
- mitigation status。
## 明确不做
- 不强制签名。
- 不接外部漏洞库作为强依赖。
- 不做双人审批。
- 不自动升级或自动启用仓库版本。
## 实现任务
1. 实现 policy engine。
2. 实现 review API/UI。
3. 实现 denylist/quarantine/revoke。
4. 实现 conflict check。
5. 实现 preflight API 和结果存储。
6. 实现 self-test profile。
7. 实现 benchmark result 存储和门禁。
8. 实现 security advisory import/rescan/ack。
9. 将门禁接入 enable、rollback、promotion import apply。
## 验收
- 未 review 的高风险 protocol-proxy 插件不能在 prod profile 启用。
- 两个同 scope protocol-proxy 插件不能同时启用。
- required feature 缺失返回 `feature_missing`
- secret 缺失阻断启用。
- advisory revoke 后不能 rollback 到受影响 artifact。
- warning override 过期后重新阻断相关操作。
## 回滚策略
- policy 变更不应立即删除运行中插件;先标记 drift/review_required 或 quarantine。
- quarantine 从 dispatch table 移除插件,新连接不进入;已有 protocol-proxy 连接按策略 drain/force close。
- 管理员可以回滚到未受阻断的旧 artifact。

View File

@@ -0,0 +1,123 @@
# 阶段 6Observability And Operations
## 目标
补齐生产运行所需的观测和运维能力metrics、业务事件、trace、日志、诊断包、后台任务、plugin_data、PluginFileStore、外部依赖治理、GC 和 Runbook。
阶段结束后,插件出问题时管理员能定位、降级、清理和恢复,而不是只能查看 gateway 日志。
## 可用性检查点
阶段结束时必须能做到:
- 管理员能看到 handler calls、duration、panic、timeout、active proxy connections。
- 插件可以上报脱敏业务事件和自定义指标。
- trace 能关联 connection、plugin handler、external dependency、backend dial。
- 插件能注册 interval/manual background task。
- 插件能使用 PluginDataStore 和 PluginFileStore并受配额限制。
- 管理员能执行 plugin_data/file/log/artifact GC dry-run 和清理。
## 范围
### Metrics
实现:
- plugin handler calls。
- duration histogram。
- errors/panic/timeout。
- active calls。
- active proxy connections。
- build duration/failures。
- external dependency requests/duration/inflight/circuit state。
- event delivery queue/drop/dead letter。
### Events 和 custom metrics
- 插件 manifest 声明 event schema。
- `EmitEvent` 接收低基数字段。
- 未声明或高基数字段拒绝或 drop。
- 最近事件摘要保留。
- custom metric schema 和低基数 label 限制。
### Tracing
- gateway 生成 connection ID 和 trace ID。
- SDK 通过 context 传递。
- 日志带 trace/connection ID。
- trace 摘要脱敏。
- 默认不向第三方依赖注入 `traceparent`,除非策略允许。
### Background Task
- interval/manual。
- run-on-start。
- jitter。
- timeout。
- non-reentrant。
- manual trigger 权限和 confirm token。
- last/next run、skipped、consecutive failures。
### Data 和 files
PluginDataStore
- schema version。
- data class。
- quota。
- retention。
- exportable 标记。
- GC。
PluginFileStore
- resources readonly。
- runtime data/cache/tmp/log/diagnostic。
- path traversal 防护。
- quota。
- retention。
- orphaned dir 检测。
### External dependencies
- endpoint、purpose、required、timeout、retry、fail policy。
- `ExternalClient` 受控 HTTP/TCP 调用。
- health check。
- circuit breaker。
- data classes。
- 最近错误摘要。
## 明确不做
- 不承诺 native plugin 无法绕过 ExternalClient。
- 不默认开启 Prometheus/OTel exporter 的完整外部集成。
- 不保存完整 packet payload、secret、token、session response。
## 实现任务
1. 实现 metrics 内部模型和 Admin API。
2. 实现 business event/custom metric SDK。
3. 实现 trace summary。
4. 实现 plugin logger 和日志摘要。
5. 实现 background task 注册和状态。
6. 实现 PluginDataStore。
7. 实现 PluginFileStore。
8. 实现 ExternalClient。
9. 实现 diagnostic package。
10. 实现 GC APIs 和 Runbook。
## 验收
- mc-auth-proxy 示例能上报 `auth.success`/`auth.failure` 摘要。
- session server 调用通过 ExternalClient 记录 latency 和错误。
- background task 超时不会阻塞连接路径。
- plugin_data 超配额时写入失败且不会无限增长 SQLite。
- 诊断包不包含 secret 明文和完整 packet。
- GC dry-run 能展示将清理的对象和大小。
## 回滚策略
- exporter 失败不能影响连接路径。
- event 队列满默认 drop不阻塞主流程。
- background task 可取消disable 插件时任务停止。
- plugin_data/file GC 先 dry-run清理操作写审计。

View File

@@ -0,0 +1,123 @@
# 阶段 7Extension Ecosystem
## 目标
在稳定的插件主路径上扩展生态能力route resolver、status ping、event subscriber、provider、middleware、rule/policy engine、Admin auth provider 和更多官方示例插件。
阶段结束后,用户可以不写完整 protocol-proxy也能用更低成本 extension point 完成常见运维需求。
## 可用性检查点
阶段结束时必须能做到:
- 官方 rule/policy 插件能完成 host rewrite、IP 黑白名单、简单限流或维护模式。
- route provider 能从外部源或缓存产生可解释 route decision。
- status ping 插件能自定义 MOTD/版本提示。
- event subscriber 能异步投递审计或插件事件。
- Admin auth provider 如果启用,不影响 MC 连接路径,本地 admin break-glass 保留。
## 范围
### Route
- `route.resolve/v1`
- `route.resolver/v1`
- route decision schema。
- provider cache。
- SQLite fallback。
- refresh action。
### Status
- `status.ping/v1`
- MOTD。
- favicon。
- online/max players。
- version text。
- maintenance window。
### Middleware
预留并选择性实现:
- `connection.filter/v1`
- `handshake.filter/v1`
必须有确定顺序、timeout、panic recover 和 fail policy。
### Provider
- provider singleton。
- priority/fallback。
- plugin dependencies。
- `auth.provider/v1` 只作为插件间认证来源复用,不给 gateway core 组装 MC 登录流程。
### Event subscriber
- best_effort。
- at_least_once。
- queue。
- retry。
- dead letter。
- replay/drop action。
### Rule / Policy Engine
官方插件形式提供:
- host rewrite。
- source CIDR allow/deny。
- simple rate limit。
- maintenance mode。
- upstream rewrite。
### Admin Auth Provider
预留或实现:
- OIDC。
- LDAP。
- external identity binding。
- break-glass local admin。
- gateway-issued session。
## 明确不做
- 不开放 play 阶段 packet filter 作为默认生产能力。
- 不让 `auth.provider/v1` 进入 gateway core MC 登录流水线。
- 不允许插件自定义 Admin 权限绕过 gateway 权限模型。
- 不允许插件注入 Admin 自定义 HTML/JS。
## 实现任务
1. 实现 route decision 模型。
2. 实现 route provider cache 和 refresh action。
3. 实现 status ping extension point。
4. 实现 event subscriber delivery。
5. 实现 provider registry。
6. 实现 rule/policy 官方插件。
7. 预留或实现 Admin auth provider。
8. 增加示例插件和 conformance fixture。
## 验收
- route provider 返回 override/fallback/reject/pass 都能在 Admin 解释。
- 外部 route source 不可用时能使用 cache 或 SQLite fallback。
- status 插件能按 host 返回不同 MOTD。
- event subscriber 失败不影响连接路径。
- rule 插件配置错误不会破坏默认路由。
- Admin auth provider 不可用时,本地 admin 仍可登录。
## 回滚策略
- route provider disable 后恢复 SQLite route snapshot。
- status 插件 disable 后恢复默认 status。
- event subscriber disable 后只停止外部投递,不删除本地审计。
- rule 插件冲突时通过 priority/scope 修复或禁用。
## 实现说明
- Route/status/middleware/provider/event subscriber 仍复用插件 `Gateway.Hook` 注册模型,新增 typed SDK 结构保持和 `upstream.connect/v1` 一致。
- 官方 rule/policy 以内置官方插件 `official.rule-policy` 提供,管理员启用后通过插件配置完成 host rewrite、source CIDR allow/deny、simple rate limit、maintenance mode 和 upstream rewrite。
- Admin auth provider 当前作为 provider registry 能力预留和展示,不进入 MC 连接路径,也不替代本地 admin break-glass 登录。
- Route provider 失败时优先使用 provider cache未命中时回退到 SQLite route snapshot。

View File

@@ -0,0 +1,180 @@
# 阶段 8Future Runtimes And Distribution
## 目标
引入最终设计中的未来能力,同时保持前七个阶段的默认路径可用。包括 `go-plugin-process`、sandbox-process、WASM、ingress service、插件仓库、签名、SBOM 漏洞扫描、许可证策略和 build-time instrumentation。
本阶段由多个可选子阶段组成。每个子阶段都必须可以单独启用或回滚,不能要求一次性切换所有 runtime。
## 可用性检查点
阶段结束时必须能做到:
- 默认 `in-process go-plugin` 路径仍然可用。
- 管理后台可以配置插件服务启动模式 desired value并明确 restart required。
- `go-plugin-process` 至少支持 drain-only。
- sandbox-process/WASM 如果启用capabilities 能被强制或阻断启用。
- 仓库导入只生成本地 artifact不自动启用生产流量。
- 签名、SBOM、license 和 advisory 策略能参与准入结果。
## 子阶段 Ago-plugin-process
实现:
- gateway 插件服务启动模式:`in-process``go-plugin-process``sandbox-process`
- Admin 系统配置项 `plugin_service.desired_mode`
- 启动时读取 desired mode校验后写入 `plugin_service.active_mode``applied_at`
- `restart_required` 由 desired/active 差异推导。
- 环境级连接迁移开关:`drain-only``fd-live``fd-live-shm`
- plugin-host supervisor。
- 子进程 lifecycle。
- UDS control channel。
- `drain-only` 进程级卸载。
- 可选 `fd-live`
- 可选 `fd-live-shm`
- shared memory state schema。
- `quiesce/snapshot/restore` conformance。
默认:
- `in-process`
- `active_mode` 只由 gateway 启动流程写入。
- `go-plugin-process` 切换需要重启。
- live migration 默认关闭。
- 迁移默认模式为 `drain-only`
验收:
- Admin 修改 `plugin_service.desired_mode` 后不立即切换当前进程,页面展示 restart required。
- 重启后 gateway 按 desired mode 创建对应 RuntimeAdapter 或 plugin-host supervisor并更新 active mode。
- `go-plugin-process` 模式下启用 upstream-rewrite。
- disable 后旧 plugin-host drain 并退出。
- 子进程退出后 `.so` 和 Go heap 被 OS 回收。
- crash loop 不影响 Admin 主进程。
## 子阶段 Bsandbox-process
实现:
- control RPC。
- supervisor。
- crash loop policy。
- secret RPC/handle。
- filesystem/network/env/cpu/memory capability enforcement。
- stream relay 或 `stream.proxy/v1`
验收:
- sandbox 插件崩溃不导致 gateway 崩溃。
- 无法强制 required capability 时阻断启用。
- secret 不通过长期环境变量注入。
## 子阶段 CWASM
实现:
- WASM host ABI。
- route/rule/config validate extension point。
- memory/time limits。
- no file/no network 默认策略。
验收:
- WASM rule 插件可以返回 allow/deny/rewrite。
- 超时或内存超限只影响当前调用。
- WASM 插件不能访问未授权 secret/network。
## 子阶段 Dingress service
实现:
- `ingress.service/v1` schema。
- service supervisor。
- listener ownership by gateway。
- port conflict check。
- TLS/secret refs。
- health and drain。
验收:
- 插件声明入口服务后,由 gateway 创建 listener。
- disable 后停止接收新连接并 drain。
- 端口冲突阻断启用。
## 子阶段 Erepository and supply chain
实现:
- official/internal/file/url repository index。
- repository trust policy。
- artifact download to local store。
- signature verification。
- SBOM vulnerability scan。
- license allowlist/denylist。
- advisory feed sync。
- update availability。
验收:
- 仓库候选版本导入后仍需本地 review。
- 仓库删除版本不删除本地 artifact。
- denylist/advisory 仍阻断 rollback 和 promotion apply。
## 子阶段 Fbuild-time instrumentation
实现:
- instrumentation manifest。
- official/organization CI profile。
- generated diff hash。
- provenance。
- conformance/benchmark/smoke gate。
验收:
- 插桩产物作为 gateway binary 发布,不进入 plugin artifact hot-load lifecycle。
- Admin 展示 instrumentation metadata。
- 插桩影响连接路径时 Runbook 说明回滚方式。
## 明确不做
- 不把 `go-plugin-process` 当成不可信 sandbox。
- 不把 fd 迁移当成跨平台通用能力。
- 不把 WASM 用于完整 MC protocol-proxy。
- 不让仓库自动启用生产插件。
- 不允许普通上传包携带插桩规则直接改 gateway binary。
## 实现任务
本阶段按子阶段逐个实施。每个子阶段都必须满足:
1. 默认 `in-process go-plugin` 路径不回归。
2. 新 runtime 或供应链能力可通过 feature flag、service mode 或 policy profile 关闭。
3. Admin/API 能展示当前 active 状态、desired 状态和失败原因。
4. promotion import 不能自动启用目标环境不支持的 runtime 或策略。
5. conformance 覆盖新增 manifest 字段、feature key、错误码和回滚路径。
建议顺序:
1. 先实现 service mode 数据模型和 Admin 展示。
2. 再实现 `go-plugin-process``drain-only`
3. 再评估 `fd-live``fd-live-shm`
4. 然后引入 sandbox-process 和 WASM。
5. 最后引入仓库、签名、SBOM 扫描和 build-time instrumentation。
## 验收
- 未开启任何 future runtime 时,阶段 1 到阶段 7 的插件能力仍通过回归验证。
- service mode 从 `in-process` 切换到 `go-plugin-process` 时明确提示 restart required。
- `go-plugin-process` 子进程 crash 不导致 Admin 主进程退出,并能展示 crash loop 状态。
- sandbox-process required capability 无法强制时,启用被阻断而不是降级为只审计。
- WASM 插件超时、panic 或内存超限只影响当前调用。
- repository import 只生成本地 artifact并重新进入本地准入、review、enable 流程。
- build-time instrumentation 产物不会出现在 runtime plugin enable/disable 列表中。
## 回滚策略
- runtime mode 切换失败时回到 `in-process`
- sandbox/WASM 插件失败时禁用对应 plugin不影响 `go-plugin` 插件。
- repository 功能失败不影响本地 artifact。
- build-time instrumentation 回滚到上一 gateway binary。

7892
docs/plugin-system-design.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,243 @@
# TCP 端口复用 Web 服务设计
## 背景
当前 gateway 的 Minecraft TCP 入口和 WebSocket/HTTP 入口分别监听端口:
- `runTcp` 使用 `net.Listen("tcp", :tcp.port)` 接收原始 Minecraft TCP 连接。
- `runWebSocket` 使用 `http.ListenAndServe(:websocket.port)` 接收 HTTP/WebSocket 连接。
- `handleRequest` 会读取连接的第一段数据,并通过 Minecraft 握手包里的 host 路由到上游。
因此当前不能把 `tcp.port``websocket.port` 配成同一个端口;两个监听器会竞争同一个 TCP 地址,后启动的服务会失败。
## 目标
在同一个 TCP 监听端口上同时提供:
- Minecraft 原始 TCP 转发服务。
- Web 服务,包括普通 HTTP 接口和 WebSocket upgrade。
入口连接根据首包内容自动分流:
- HTTP/WebSocket 首包进入 Web 服务。
- 其他首包进入现有 Minecraft TCP 转发流程。
## 非目标
- 不在本次设计中实现代码。
- 不改变 KCP、QUIC 的端口与协议行为。
- 不实现 HTTPS/TLS 复用。HTTPS 需要在 gateway 内终止 TLS或由外部代理终止 TLS 后再转发明文 HTTP。
- 不改变现有 Minecraft host 路由规则。
- 不改变现有上游协议选择规则,例如 `quic://``kcp://``haproxy://`
## 配置设计
不新增复用开关,复用行为由端口配置自动决定:
```toml
[tcp]
enable = true
port = 25565
[websocket]
enable = true
port = 25566
path = "/"
```
端口判定规则:
- `tcp.enable = true``websocket.enable = true``tcp.port == websocket.port` 时,自动进入端口复用模式。
- `tcp.enable = true``websocket.enable = true``tcp.port != websocket.port` 时,保持现有独立监听模式。
- 只有 `tcp.enable = true` 时,只启动 Minecraft TCP 服务。
- 只有 `websocket.enable = true` 时,只启动 Web 服务。
- 端口比较应使用默认值归一化后的结果,例如 `websocket.port = 0` 应先按当前默认值视为 `25566`
自动复用模式下:
- Web 服务不再单独监听 `websocket.port`
- Web 服务挂载到 TCP listener 的 HTTP 分流分支。
- 实际对外端口为 `tcp.port`
- `websocket.port``tcp.port` 相同,作为触发自动复用的配置表达。
## 总体架构
复用模式下只有一个 TCP listener
```text
net.Listen(:tcp.port)
|
v
Accept
|
v
读取首批字节
|
+-- HTTP/WebSocket
| -> HTTP channel listener
| -> http.Server.Serve
|
+-- Minecraft TCP
-> 回放首批字节
-> handleRequest
-> mapToHost
-> proxyConnections
```
关键点:首包只用于判断协议,不能被消费掉。分流后必须把已经读取的字节重新接回连接流。
## 协议识别规则
HTTP/WebSocket 都以 HTTP 请求开始,因此只需要识别 HTTP 方法前缀。
建议识别以下前缀:
- `GET `
- `POST `
- `HEAD `
- `PUT `
- `PATCH `
- `DELETE `
- `OPTIONS `
- `CONNECT `
- `TRACE `
WebSocket upgrade 请求通常是 `GET /path HTTP/1.1`,会自然进入 HTTP 分支,再由现有 WebSocket handler 处理。
未匹配 HTTP 方法前缀的连接全部进入 Minecraft TCP 分支。
## 首包读取与回放
新增一个包装连接,例如 `replayConn`
```go
type replayConn struct {
net.Conn
reader io.Reader
}
```
创建时把已读取的首包和原始连接拼接:
```go
reader := io.MultiReader(bytes.NewReader(peeked), conn)
```
之后 `Read``reader` 读取,`Write``Close`、deadline、地址信息继续委托给底层 `net.Conn`
这样 Minecraft 分支仍然可以使用现有 `handleRequest(replayConn)``mapToHost` 读到的内容和没有分流时一致。
## HTTP 接入方式
不建议手写 HTTP 解析。建议实现一个 channel listener
```go
type chanListener struct {
conns chan net.Conn
closed chan struct{}
addr net.Addr
}
```
行为:
- 主 TCP accept 循环识别到 HTTP 后,把 `replayConn` 投递到 `chanListener.conns`
- `http.Server.Serve(chanListener)` 负责标准 HTTP/WebSocket 处理。
- 关闭 gateway 时关闭 `chanListener`,让 HTTP server 退出。
优点:
- 继续使用 Go 标准库 HTTP server。
- WebSocket upgrade 流程不需要重写。
- 可以复用现有 `handleWebSocket`
## 运行流程
自动复用模式启动流程:
1. 加载配置。
2. 创建 Web handler 和 `http.Server`,但不调用 `ListenAndServe`
3. 创建 TCP listener。
4. 启动 `http.Server.Serve(chanListener)`
5. TCP accept 循环接收所有连接。
6. 每个连接设置 socket option。
7. 设置短读超时读取首批字节。
8. 根据首包分流到 Web 或 Minecraft。
非复用模式保持现有流程:
- TCP 服务继续由 `runTcp` 独立监听。
- WebSocket 服务继续由 `runWebSocket` 独立监听。
## 超时与错误处理
首包读取需要短超时,避免空连接或慢连接长期占用 goroutine。
建议策略:
- 首包读取超时:关闭连接并记录 debug 或 warn 日志。
- 首包为空:关闭连接。
- HTTP 分支投递失败:关闭连接。
- Minecraft 分支继续使用现有错误处理。
读超时只用于首包判断。分流完成后应清除 read deadline避免影响长连接转发。
## 与现有热路径的关系
Minecraft TCP 分支应尽量保持现有转发路径:
- `handleRequest`
- `mapToHost`
- `proxyConnections`
- `copyForward`
复用逻辑只出现在 listener 和首包分流层,不进入双向转发热路径。这样可以避免破坏已有 TCP 转发优化。
## 测试计划
单元测试:
- HTTP 方法前缀识别。
- 非 HTTP 首包进入 Minecraft 分支。
- `replayConn` 能先读出已窥探字节,再读底层连接后续字节。
- 首包读取超时会关闭连接。
- `chanListener``Accept``Close` 行为。
集成测试:
- 复用模式下,同一端口可以访问 HTTP 接口。
- 复用模式下,同一端口可以完成 WebSocket upgrade。
- 复用模式下Minecraft 握手包仍能被 `protocol.GetMcHost` 正确解析。
- 非复用模式下,现有 `tcp.port``websocket.port` 行为不变。
- `tcp.port == websocket.port` 时自动复用,不应出现两个 listener 竞争同一端口。
性能验证:
- 对 Minecraft TCP 转发路径跑现有 benchmark确认复用层没有影响已建立连接后的转发性能。
- 至少覆盖 loopback TCP 场景,因为该项目的主要热路径是 TCP-to-TCP 转发。
## 风险与边界
- HTTP 识别只能覆盖明文 HTTP。TLS 握手首包不会匹配 HTTP 方法,会被送入 Minecraft 分支并失败。
- 某些非 HTTP 协议如果首包刚好以 `GET ` 等方法前缀开头,会被误判为 Web 请求;对 Minecraft Java 握手来说风险很低。
- 首包读取缓冲区不能太小,否则可能影响识别;但 HTTP 方法识别只需要很少字节。
- 复用模式改变 listener 所有权,需要注意进程退出时 TCP listener、HTTP server、channel listener 的关闭顺序。
## 实施步骤
1. 增加端口默认值归一化和启动模式判定逻辑。
2. 新增 HTTP 方法识别函数。
3. 新增 `replayConn`
4. 新增 `chanListener`
5. 拆分 WebSocket handler 注册逻辑,让自动复用模式和独立监听模式都能使用同一套 handler。
6. 新增自动复用模式的 TCP accept 分流入口。
7. 增加配置校验,避免启动两个 listener 监听同一 TCP 地址。
8. 补充单元测试、集成测试和 TCP benchmark 验证。
## 验收标准
-`tcp.port == websocket.port` 且两个服务都启用时,只暴露该端口也能同时提供 Minecraft TCP 和 WebSocket/HTTP 服务。
- Minecraft 客户端连接、host 路由、上游转发行为与复用前一致。
- WebSocket 客户端通过同一端口能完成 upgrade 并进入现有 gateway 流程。
- 端口不同或只启用单个服务时,现有配置和行为不变。
- 测试覆盖首包识别、连接回放、HTTP 分流、Minecraft 分流和配置校验。

View File

@@ -0,0 +1,12 @@
# Extension Ecosystem Example
This example demonstrates phase 7 extension points:
- `route.resolve/v1` returns `override`, `reject`, or `pass`.
- `status.ping/v1` returns host-specific MOTD text.
- `event.subscriber/v1` receives asynchronous plugin events.
- `admin.auth.provider/v1` registers an unavailable external provider while preserving local admin fallback.
It is intended as a conformance fixture and source example for plugin authors.
The source manifest is maintained as `manifest.yaml`; packaged `.mcgp` artifacts
still contain canonical `manifest.json`.

View File

@@ -0,0 +1,8 @@
{
"route_decisions": ["override", "fallback", "reject", "pass"],
"status_hosts": ["blue.example", "red.example"],
"event_delivery": ["best_effort", "at_least_once", "dead_letter"],
"provider_registry": ["singleton", "priority", "fallback", "admin.auth.provider/v1"],
"default_route_must_survive_bad_rule_config": true,
"local_admin_break_glass": true
}

View File

@@ -0,0 +1,51 @@
# examples/plugins/extension-ecosystem/manifest.yaml 是示例插件代码,用于演示托管插件接入方式。
# 人工维护的插件清单;构建插件包时会规范化为 manifest.json。
schema_version: mc-gateway.plugin/v1
id: extension-ecosystem-example
name: Extension Ecosystem Example
version: 0.1.0
description: Example fixture for route, status, subscriber and provider extension points.
artifact_type: binary
runtime:
type: go-plugin
entry: plugin.so
entry_symbol: Plugin
api_version: plugin-api/v1
sdk_module: github.com/tursom/mc-gateway/plugin/api
sdk_module_version: v0.1.0
go_version: go1.24.0
go_os: linux
go_arch: amd64
extension_points:
- type: provider
key: route.resolve/v1
- type: hook
key: status.ping/v1
- type: event
key: event.subscriber/v1
- type: provider
key: admin.auth.provider/v1
capabilities:
extension_points:
- route.resolve/v1
- status.ping/v1
- event.subscriber/v1
- admin.auth.provider/v1
route:
cache_ttl_ms: 60000
status:
hosts:
- blue.example
- red.example
event_subscriber:
mode: at_least_once
max_retry: 3
providers:
- type: admin.auth.provider/v1
name: external-identity
fallback: true
runtime_limits:
handler_timeout_ms: 1000
config_schema:
type: object

Some files were not shown because too many files have changed in this diff Show More