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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ Gateway 子命令(Step 1 骨架):
go run ./cmd/neocode gateway
```

指定网络访问面监听地址(默认 `127.0.0.1:8080`,仅允许 Loopback):

```bash
go run ./cmd/neocode gateway --http-listen 127.0.0.1:8080
```

网络访问面骨架端点(EPIC-GW-04):

- `POST /rpc`:单次 JSON-RPC 请求入口
Comment thread
fennoai[bot] marked this conversation as resolved.
- `GET /ws`:WebSocket 流式入口(含心跳)
- `GET /sse`:SSE 流式入口(MVP 默认触发 `gateway.ping`,含心跳)

安全限制:为防止跨站攻击,网关网络面默认开启严格的 Origin 校验。当前仅允许
`http://localhost`、`http://127.0.0.1`、`http://[::1]` 以及 `app://` 前缀来源连入;
非允许来源的跨域调用会被拦截并返回 `403`。
注:上述白名单机制仅针对携带 `Origin` 头的浏览器跨站请求生效。若请求不携带 `Origin` 头
(例如 `cURL`、Postman 或本地后端脚本直连),网关默认放行。

URL Scheme 派发骨架命令(EPIC-GW-02A):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs say only allowlisted origins are allowed, but implementation permits requests with empty Origin (including WS handshake with no origin). Please clarify this in README (e.g. "allowlist applies to requests that include Origin") or tighten server behavior to match current wording.


```bash
Expand Down
50 changes: 46 additions & 4 deletions internal/cli/gateway_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var (
runGatewayCommand = defaultGatewayCommandRunner
runURLDispatchCommand = defaultURLDispatchCommandRunner
newGatewayServer = defaultNewGatewayServer
newGatewayNetwork = defaultNewGatewayNetworkServer
dispatchURLThroughIPC = urlscheme.Dispatch
exitProcess = os.Exit
writeDispatchError = writeURLDispatchErrorOutput
Expand All @@ -35,6 +36,7 @@ var (

type gatewayCommandOptions struct {
ListenAddress string
HTTPAddress string
LogLevel string
}

Expand Down Expand Up @@ -63,6 +65,12 @@ type gatewayServer interface {
Close(ctx context.Context) error
}

type gatewayNetworkServer interface {
ListenAddress() string
Serve(ctx context.Context, runtimePort gateway.RuntimePort) error
Close(ctx context.Context) error
}

// newGatewayCommand 创建并返回网关子命令,负责启动本地 Gateway 进程。
func newGatewayCommand() *cobra.Command {
options := &gatewayCommandOptions{}
Expand All @@ -80,12 +88,19 @@ func newGatewayCommand() *cobra.Command {

return runGatewayCommand(cmd.Context(), gatewayCommandOptions{
ListenAddress: strings.TrimSpace(options.ListenAddress),
HTTPAddress: strings.TrimSpace(options.HTTPAddress),
LogLevel: normalizedLogLevel,
})
},
}

cmd.Flags().StringVar(&options.ListenAddress, "listen", "", "gateway listen address (optional override)")
cmd.Flags().StringVar(
&options.HTTPAddress,
"http-listen",
gateway.DefaultNetworkListenAddress,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--http-listen defaults to fixed 127.0.0.1:8080, and the command now treats network listener failure as fatal for the whole gateway startup. This is a behavioral regression vs prior IPC-only startup: if 8080 is occupied, gateway cannot start at all. Consider making network listener opt-in, defaulting to :0, or degrading gracefully when only the network plane fails.

"gateway network listen address (loopback only)",
)
cmd.Flags().StringVar(&options.LogLevel, "log-level", defaultGatewayLogLevel, "gateway log level: debug|info|warn|error")

return cmd
Expand All @@ -110,26 +125,53 @@ func defaultGatewayCommandRunner(ctx context.Context, options gatewayCommandOpti
signalContext, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()

server, err := newGatewayServer(gateway.ServerOptions{
ipcServer, err := newGatewayServer(gateway.ServerOptions{
ListenAddress: options.ListenAddress,
Logger: logger,
})
if err != nil {
return err
}
networkServer, err := newGatewayNetwork(gateway.NetworkServerOptions{
ListenAddress: options.HTTPAddress,
Logger: logger,
})
if err != nil {
_ = ipcServer.Close(context.Background())
return err
}
defer func() {
_ = server.Close(context.Background())
_ = networkServer.Close(context.Background())
_ = ipcServer.Close(context.Background())
}()

logger.Printf("gateway listen address: %s", server.ListenAddress())
return server.Serve(signalContext, nil)
logger.Printf("gateway ipc listen address: %s", ipcServer.ListenAddress())
logger.Printf("gateway network listen address: %s", networkServer.ListenAddress())

go func() {
serveErr := networkServer.Serve(signalContext, nil)
if serveErr != nil && signalContext.Err() == nil {
logger.Printf(
"warning: HTTP server failed to start on %s (port in use?), but IPC server is still running: %v",
networkServer.ListenAddress(),
serveErr,
)
}
}()

return ipcServer.Serve(signalContext, nil)
}

// defaultNewGatewayServer 创建默认网关服务实例,供命令层启动流程调用。
func defaultNewGatewayServer(options gateway.ServerOptions) (gatewayServer, error) {
return gateway.NewServer(options)
}

// defaultNewGatewayNetworkServer 创建默认网关网络访问面服务实例,供命令层启动流程调用。
func defaultNewGatewayNetworkServer(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return gateway.NewNetworkServer(options)
}

// newURLDispatchCommand 创建 URL Scheme 派发子命令骨架,仅做参数收敛与调用转发。
func newURLDispatchCommand() *cobra.Command {
options := &urlDispatchCommandOptions{}
Expand Down
113 changes: 112 additions & 1 deletion internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ func TestGatewaySubcommandPassesFlagsToRunner(t *testing.T) {
}

command := NewRootCommand()
command.SetArgs([]string{"gateway", "--listen", " /tmp/gateway.sock ", "--log-level", " WARN "})
command.SetArgs([]string{
"gateway",
"--listen", " /tmp/gateway.sock ",
"--http-listen", " 127.0.0.1:19080 ",
"--log-level", " WARN ",
})
if err := command.ExecuteContext(context.Background()); err != nil {
t.Fatalf("ExecuteContext() error = %v", err)
}
Expand All @@ -200,6 +205,9 @@ func TestGatewaySubcommandPassesFlagsToRunner(t *testing.T) {
if captured.LogLevel != "warn" {
t.Fatalf("log level = %q, want %q", captured.LogLevel, "warn")
}
if captured.HTTPAddress != "127.0.0.1:19080" {
t.Fatalf("http address = %q, want %q", captured.HTTPAddress, "127.0.0.1:19080")
}
}

func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) {
Expand All @@ -220,15 +228,22 @@ func TestGatewaySubcommandRejectsInvalidLogLevel(t *testing.T) {

func TestDefaultGatewayCommandRunnerSuccess(t *testing.T) {
originalNewGatewayServer := newGatewayServer
originalNewGatewayNetwork := newGatewayNetwork
t.Cleanup(func() { newGatewayServer = originalNewGatewayServer })
t.Cleanup(func() { newGatewayNetwork = originalNewGatewayNetwork })

server := &stubGatewayServer{listenAddress: "stub://gateway"}
newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) {
return server, nil
}
networkServer := &stubGatewayServer{listenAddress: "127.0.0.1:8080"}
newGatewayNetwork = func(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return networkServer, nil
}

err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{
ListenAddress: "stub://gateway",
HTTPAddress: "127.0.0.1:8080",
LogLevel: "info",
})
if err != nil {
Expand All @@ -240,19 +255,28 @@ func TestDefaultGatewayCommandRunnerSuccess(t *testing.T) {
if !server.closeCalled {
t.Fatal("expected server Close to be called")
}
if !networkServer.closeCalled {
t.Fatal("expected network server Close to be called")
}
}

func TestDefaultGatewayCommandRunnerReturnsConstructorError(t *testing.T) {
originalNewGatewayServer := newGatewayServer
originalNewGatewayNetwork := newGatewayNetwork
t.Cleanup(func() { newGatewayServer = originalNewGatewayServer })
t.Cleanup(func() { newGatewayNetwork = originalNewGatewayNetwork })

expected := errors.New("new gateway server failed")
newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) {
return nil, expected
}
newGatewayNetwork = func(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return &stubGatewayServer{listenAddress: "127.0.0.1:8080"}, nil
}

err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{
ListenAddress: "stub://gateway",
HTTPAddress: "127.0.0.1:8080",
LogLevel: "info",
})
if !errors.Is(err, expected) {
Expand All @@ -262,7 +286,9 @@ func TestDefaultGatewayCommandRunnerReturnsConstructorError(t *testing.T) {

func TestDefaultGatewayCommandRunnerReturnsServeError(t *testing.T) {
originalNewGatewayServer := newGatewayServer
originalNewGatewayNetwork := newGatewayNetwork
t.Cleanup(func() { newGatewayServer = originalNewGatewayServer })
t.Cleanup(func() { newGatewayNetwork = originalNewGatewayNetwork })

expected := errors.New("serve failed")
server := &stubGatewayServer{
Expand All @@ -272,9 +298,14 @@ func TestDefaultGatewayCommandRunnerReturnsServeError(t *testing.T) {
newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) {
return server, nil
}
networkServer := &stubGatewayServer{listenAddress: "127.0.0.1:8080"}
newGatewayNetwork = func(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return networkServer, nil
}

err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{
ListenAddress: "stub://gateway",
HTTPAddress: "127.0.0.1:8080",
LogLevel: "info",
})
if !errors.Is(err, expected) {
Expand All @@ -283,6 +314,74 @@ func TestDefaultGatewayCommandRunnerReturnsServeError(t *testing.T) {
if !server.closeCalled {
t.Fatal("expected server Close to be called")
}
if !networkServer.closeCalled {
t.Fatal("expected network server Close to be called")
}
}

func TestDefaultGatewayCommandRunnerDegradesWhenNetworkServeFails(t *testing.T) {
originalNewGatewayServer := newGatewayServer
originalNewGatewayNetwork := newGatewayNetwork
t.Cleanup(func() { newGatewayServer = originalNewGatewayServer })
t.Cleanup(func() { newGatewayNetwork = originalNewGatewayNetwork })

ipcServer := &stubGatewayServer{listenAddress: "stub://gateway"}
newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) {
return ipcServer, nil
}
networkServer := &stubGatewayServer{
listenAddress: "127.0.0.1:8080",
serveErr: errors.New("bind: address already in use"),
}
newGatewayNetwork = func(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return networkServer, nil
}

err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{
ListenAddress: "stub://gateway",
HTTPAddress: "127.0.0.1:8080",
LogLevel: "info",
})
if err != nil {
t.Fatalf("expected graceful degradation on network serve error, got %v", err)
}
if !ipcServer.serveCalled {
t.Fatal("expected ipc server Serve to be called")
}
if !ipcServer.closeCalled {
t.Fatal("expected ipc server Close to be called")
}
if !networkServer.closeCalled {
t.Fatal("expected network server Close to be called")
}
}

func TestDefaultGatewayCommandRunnerReturnsNetworkConstructorError(t *testing.T) {
originalNewGatewayServer := newGatewayServer
originalNewGatewayNetwork := newGatewayNetwork
t.Cleanup(func() { newGatewayServer = originalNewGatewayServer })
t.Cleanup(func() { newGatewayNetwork = originalNewGatewayNetwork })

networkErr := errors.New("new network server failed")
ipcServer := &stubGatewayServer{listenAddress: "stub://gateway"}
newGatewayServer = func(options gateway.ServerOptions) (gatewayServer, error) {
return ipcServer, nil
}
newGatewayNetwork = func(options gateway.NetworkServerOptions) (gatewayNetworkServer, error) {
return nil, networkErr
}

err := defaultGatewayCommandRunner(context.Background(), gatewayCommandOptions{
ListenAddress: "stub://gateway",
HTTPAddress: "127.0.0.1:8080",
LogLevel: "info",
})
if !errors.Is(err, networkErr) {
t.Fatalf("expected network constructor error %v, got %v", networkErr, err)
}
if !ipcServer.closeCalled {
t.Fatal("expected ipc server Close to be called when network constructor fails")
}
}

func TestDefaultNewGatewayServer(t *testing.T) {
Expand All @@ -297,6 +396,18 @@ func TestDefaultNewGatewayServer(t *testing.T) {
}
}

func TestDefaultNewGatewayNetworkServer(t *testing.T) {
server, err := defaultNewGatewayNetworkServer(gateway.NetworkServerOptions{
ListenAddress: "127.0.0.1:8080",
})
if err != nil {
t.Fatalf("defaultNewGatewayNetworkServer() error = %v", err)
}
if server == nil {
t.Fatal("defaultNewGatewayNetworkServer() returned nil server")
}
}

func TestURLDispatchSubcommandUsesURLFlag(t *testing.T) {
originalRunner := runURLDispatchCommand
originalPreload := runGlobalPreload
Expand Down
Loading
Loading