diff --git a/Dockerfile b/Dockerfile index 86f15d8..b4b0a5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,10 +31,13 @@ COPY pkg pkg/ # RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on taskset -c 1 /usr/local/go/bin/go build -a -o q3 ./cmd/q3 RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on go build -a -o q3 ./cmd/q3 +RUN CGO_ENABLED=0 go install github.com/grpc-ecosystem/grpc-health-probe@latest + FROM alpine:3 COPY --from=builder /workspace/q3 /usr/local/bin +COPY --from=builder /go/bin/grpc-health-probe /usr/local/bin/grpc-health-probe COPY --from=quake-n-bake /usr/local/bin/ioq3ded /usr/local/bin COPY --from=quake-n-bake /lib/ld-musl-*.so.1 /lib -ENTRYPOINT ["/usr/local/bin/q3"] +CMD ["/usr/local/bin/q3", "/usr/local/bin/grpc-health-probe"] diff --git a/Tiltfile b/Tiltfile index a356aa9..2cf32d9 100644 --- a/Tiltfile +++ b/Tiltfile @@ -8,4 +8,7 @@ k8s_yaml( ] ) ) + +# Using tilt port_forward doesn't work with graceful pod termination, the +# port_forward is closed as soon as the deployment changes. k8s_resource('quake-kube-chart', port_forwards='30001:8080') diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 03c72c0..b8c3412 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -9,7 +9,11 @@ spec: selector: matchLabels: {{- include "chart.selectorLabels" . | nindent 6 }} - run: quake-kube + app: quake-kube + strategy: + rollingUpdate: + # This should be set to 0 if we only have 1 replica defined + maxUnavailable: 0 template: metadata: {{- with .Values.podAnnotations }} @@ -21,6 +25,8 @@ spec: {{- with .Values.podLabels }} {{- toYaml . | nindent 8 }} {{- end }} + version: "2" + app: quake-kube spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: @@ -29,56 +35,40 @@ spec: serviceAccountName: {{ include "chart.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + terminationGracePeriodSeconds: 180 containers: - # - name: {{ .Chart.Name }} - name: server command: - q3 - - server + - run - --config=/config/config.yaml - - --content-server=http://127.0.0.1:9090 - --agree-eula + - --shutdown-delay=10s + - --seed-content-url=http://content.quakejs.com securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: 8080 - # livenessProbe: - # httpGet: - # path: / - # port: http + livenessProbe: + exec: + command: + - grpc-health-probe + - -addr=localhost:8080 + initialDelaySeconds: 30 + failureThreshold: 1 + successThreshold: 1 + periodSeconds: 10 # this is 3 times the period on readinessProbe readinessProbe: - tcpSocket: - port: 8080 - initialDelaySeconds: 15 - periodSeconds: 5 - resources: - {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.volumeMounts }} - volumeMounts: - {{- toYaml . | nindent 12 }} - {{- end }} - - name: content-server - command: - - q3 - - content - - --seed-content-url=http://content.quakejs.com - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - containerPort: 9090 - # livenessProbe: - # httpGet: - # path: / - # port: http - # readinessProbe: - # tcpSocket: - # port: 8080 - # initialDelaySeconds: 15 - # periodSeconds: 5 + exec: + command: + - grpc-health-probe + - -addr=localhost:8080 + initialDelaySeconds: 5 + failureThreshold: 3 + successThreshold: 1 + periodSeconds: 3 resources: {{- toYaml .Values.resources | nindent 12 }} {{- with .Values.volumeMounts }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml index b7486dd..f911991 100644 --- a/chart/templates/service.yaml +++ b/chart/templates/service.yaml @@ -9,16 +9,9 @@ spec: ports: - port: 8080 targetPort: 8080 - nodePort: 30001 + nodePort: 30000 + protocol: TCP name: client - - port: 27960 - targetPort: 27960 - nodePort: 30003 - name: server - - port: 9090 - targetPort: 9090 - nodePort: 30002 - name: content selector: {{- include "chart.selectorLabels" . | nindent 4 }} - run: quake-kube + app: quake-kube diff --git a/cmd/q3/app/content/content.go b/cmd/q3/app/content/content.go index 366f504..7e0f509 100644 --- a/cmd/q3/app/content/content.go +++ b/cmd/q3/app/content/content.go @@ -1,6 +1,7 @@ package content import ( + "context" "fmt" "net" "net/url" @@ -18,6 +19,7 @@ import ( var opts struct { Addr string + ServerAddr string AssetsDir string SeedContentURL string } @@ -50,16 +52,21 @@ func NewCommand() *cobra.Command { } } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + m := mux.New(must.Must(net.Listen("tcp", opts.Addr))) - m.Register(quakecontent.NewRPCServer(opts.AssetsDir)). + m.Register(quakecontent.NewRPCServer(ctx, opts.AssetsDir, opts.ServerAddr)). Match(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) - m.Register(quakecontent.NewHTTPContentServer(opts.AssetsDir)). + m.Register(quakecontent.NewHTTPContentServer(ctx, opts.AssetsDir)). Any() fmt.Printf("Starting server %s\n", opts.Addr) return m.Serve() }, } cmd.Flags().StringVarP(&opts.Addr, "addr", "a", ":9090", "address :") + cmd.Flags(). + StringVar(&opts.ServerAddr, "server-addr", "", "(optional) dedicated server :") cmd.Flags().StringVarP(&opts.AssetsDir, "assets-dir", "d", "assets", "assets directory") cmd.Flags().StringVar(&opts.SeedContentURL, "seed-content-url", "", "seed content from another content server") return cmd diff --git a/cmd/q3/app/proxy/proxy.go b/cmd/q3/app/proxy/proxy.go index fd0649f..b666c65 100644 --- a/cmd/q3/app/proxy/proxy.go +++ b/cmd/q3/app/proxy/proxy.go @@ -1,6 +1,7 @@ package proxy import ( + "context" "fmt" "net/http" @@ -29,7 +30,11 @@ func NewCommand() *cobra.Command { } opts.ClientAddr = fmt.Sprintf("%s:8080", hostIPv4) } - p, err := quakeclient.NewProxy(opts.ServerAddr) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + p, err := quakeclient.NewProxy(ctx, opts.ServerAddr) if err != nil { return err } diff --git a/cmd/q3/app/run/run.go b/cmd/q3/app/run/run.go new file mode 100644 index 0000000..f5ab07a --- /dev/null +++ b/cmd/q3/app/run/run.go @@ -0,0 +1,115 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/url" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/soheilhy/cmux" + "github.com/spf13/cobra" + + quakeclient "github.com/ChrisRx/quake-kube/internal/quake/client" + "github.com/ChrisRx/quake-kube/internal/quake/content" + quakecontentutil "github.com/ChrisRx/quake-kube/internal/quake/content/util" + quakeserver "github.com/ChrisRx/quake-kube/internal/quake/server" + . "github.com/ChrisRx/quake-kube/pkg/must" + "github.com/ChrisRx/quake-kube/pkg/mux" +) + +var opts struct { + ClientAddr string + ServerAddr string + ContentServer string + AcceptEula bool + AssetsDir string + ConfigFile string + WatchInterval time.Duration + ShutdownDelay time.Duration + SeedContentURL string +} + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "run QuakeKube", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) (err error) { + if !filepath.IsAbs(opts.AssetsDir) { + opts.AssetsDir, err = filepath.Abs(opts.AssetsDir) + if err != nil { + return err + } + } + + if !opts.AcceptEula { + fmt.Println(quakeserver.Q3DemoEULA) + return errors.New("You must agree to the EULA to continue") + } + + // Create the assets directory using the embedded game files first, and + // then any seeded content. + if err := quakeserver.ExtractGameFiles(opts.AssetsDir); err != nil { + return err + } + if opts.SeedContentURL != "" { + if err := quakecontentutil.DownloadAssets(Must(url.Parse(opts.SeedContentURL)), opts.AssetsDir); err != nil { + return err + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer stop() + + qs := quakeserver.Server{ + Addr: opts.ServerAddr, + ConfigFile: opts.ConfigFile, + Dir: opts.AssetsDir, + WatchInterval: opts.WatchInterval, + ShutdownDelay: opts.ShutdownDelay, + } + go func() { + defer cancel() + + if err := qs.Start(sctx); err != nil { + log.Printf("quakeserver: %v\n", err) + } + }() + + m := mux.New(Must(net.Listen("tcp", opts.ClientAddr))) + m.Register(content.NewRPCServer(ctx, opts.AssetsDir, opts.ServerAddr)). + Match(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc")) + m.Register(content.NewHTTPContentServer(ctx, opts.AssetsDir)). + Match(cmux.PrefixMatcher("GET /assets")) + m.Register(Must(quakeclient.NewProxy(ctx, opts.ServerAddr))). + Match(cmux.HTTP1HeaderField("Upgrade", "websocket")) + m.Register(Must(quakeclient.NewHTTPClientServer(ctx, &quakeclient.Config{ + ContentServerURL: opts.ContentServer, + ServerAddr: opts.ServerAddr, + }))). + Any() + fmt.Printf("Starting server %s\n", opts.ClientAddr) + return m.ServeAndWait() + }, + } + cmd.Flags().StringVarP(&opts.ConfigFile, "config", "c", "", "server configuration file") + cmd.Flags().StringVar(&opts.ContentServer, "content-server", "http://127.0.0.1:8080", "content server url") + cmd.Flags().BoolVar(&opts.AcceptEula, "agree-eula", false, "agree to the Quake 3 demo EULA") + cmd.Flags().StringVar(&opts.AssetsDir, "assets-dir", "assets", "location for game files") + cmd.Flags().StringVar(&opts.ClientAddr, "client-addr", "0.0.0.0:8080", "client address :") + cmd.Flags().StringVar(&opts.ServerAddr, "server-addr", "0.0.0.0:27960", "dedicated server :") + cmd.Flags().DurationVar(&opts.WatchInterval, "watch-interval", 15*time.Second, "watch interval for config file") + cmd.Flags().DurationVar(&opts.ShutdownDelay, "shutdown-delay", 1*time.Minute, "delay for graceful shutdown") + cmd.Flags().StringVar(&opts.SeedContentURL, "seed-content-url", "", "seed content from another content server") + return cmd +} diff --git a/cmd/q3/app/server/server.go b/cmd/q3/app/server/server.go index 347b712..9482f2a 100644 --- a/cmd/q3/app/server/server.go +++ b/cmd/q3/app/server/server.go @@ -73,9 +73,9 @@ func NewCommand() *cobra.Command { }() m := mux.New(must.Must(net.Listen("tcp", opts.ClientAddr))) - m.Register(must.Must(quakeclient.NewProxy(opts.ServerAddr))). + m.Register(must.Must(quakeclient.NewProxy(ctx, opts.ServerAddr))). Match(cmux.HTTP1HeaderField("Upgrade", "websocket")) - m.Register(must.Must(quakeclient.NewHTTPClientServer(&quakeclient.Config{ + m.Register(must.Must(quakeclient.NewHTTPClientServer(ctx, &quakeclient.Config{ ContentServerURL: opts.ContentServer, ServerAddr: opts.ServerAddr, }))). diff --git a/cmd/q3/main.go b/cmd/q3/main.go index c612ea2..556bc5e 100644 --- a/cmd/q3/main.go +++ b/cmd/q3/main.go @@ -8,6 +8,7 @@ import ( q3cmd "github.com/ChrisRx/quake-kube/cmd/q3/app/cmd" q3content "github.com/ChrisRx/quake-kube/cmd/q3/app/content" q3proxy "github.com/ChrisRx/quake-kube/cmd/q3/app/proxy" + q3run "github.com/ChrisRx/quake-kube/cmd/q3/app/run" q3server "github.com/ChrisRx/quake-kube/cmd/q3/app/server" q3upload "github.com/ChrisRx/quake-kube/cmd/q3/app/upload" ) @@ -25,6 +26,7 @@ func main() { q3cmd.NewCommand(), q3content.NewCommand(), q3proxy.NewCommand(), + q3run.NewCommand(), q3server.NewCommand(), q3upload.NewCommand(), ) diff --git a/docs/src/README.md b/docs/src/README.md index a206503..967def6 100644 --- a/docs/src/README.md +++ b/docs/src/README.md @@ -9,8 +9,8 @@ QuakeKube is a Kubernetes-ified version of [Quake 3](https://en.wikipedia.org/wi This uses files from the Quake 3 Demo. The demo doesn't allow custom games, so while you can add new maps, you couldn't say load up [Urban Terror](https://www.moddb.com/mods/urban-terror). I think with pak files from a full version of the game this would be possible, but I haven't tried it (maybe one day). -Another caveat is that the copy running in the browser is using [QuakeJS](https://github.com/inolen/quakejs). This version is an older verison of [ioquake3](https://github.com/ioquake/ioq3) built with [emscripten](https://emscripten.org/) and it does not appear to be supported, nor does it still compile with any newer versions of empscripten. I believe this could be made to work again, but I haven't personally looked at how involved it would be. It is worth noting that any non-browser versions of the Quake 3 could connect to the dedicated servers. +Another caveat is that the copy running in the browser is using [QuakeJS](https://github.com/inolen/quakejs). This version is an older verison of [ioquake3](https://github.com/ioquake/ioq3) built with [emscripten](https://emscripten.org/) and it does not appear to be supported, nor does it still compile with any newer versions of emscripten. I believe this could be made to work again, but I haven't personally looked at how involved it would be. It is worth noting that any non-browser versions of the Quake 3 could connect to the dedicated servers. ## What is this project for? -This was just made for fun and learning. It isn't trying to be a complete solution for managing Quake 3 on Kubernetes, and I am using it now as a repo of common patterns and best practices (IMO) for Go/Kubernetes projects. I think some fun additions though might be adding code to work as a Quake 3 master server, a server that exchanges information with the game client about what dedicated game servers, and making a controller/crd that ties it all together. +This was just made for fun and learning. It isn't trying to be a complete solution for managing Quake 3 on Kubernetes, and I am using it now as a repo of common patterns and best practices (IMO) for Go/Kubernetes projects. I think some fun additions though might be adding code to work as a Quake 3 master server, a server that exchanges information with the game client about what dedicated game servers are available, and making a controller/crd that ties it all together. diff --git a/docs/src/how-it-works.md b/docs/src/how-it-works.md index f23f591..d738100 100644 --- a/docs/src/how-it-works.md +++ b/docs/src/how-it-works.md @@ -6,7 +6,7 @@ QuakeKube makes use of [ioquake](https://www.ioquake.org) for the Quake 3 dedica The client/server protocol of Quake 3 uses UDP to synchronize game state. Browsers do not natively support sending UDP packets so QuakeJS wraps the client and dedicated server net code in websockets, allowing the browser-based clients to send messages and enable multiplayer for other clients. This ends up preventing the browser client from using any other Quake 3 dedicated server. In order to use other Quake 3 dedicated servers, a proxy handles websocket traffic coming from browser clients and translates that into UDP to the backend. This gives the flexibility of being able to talk to other existing Quake 3 servers, but also allows using ioquake (instead of the javascript translation of it), which uses *considerably* less CPU and memory. -QuakeKube also uses a cool trick with [cmux](https://github.com/cockroachdb/cmux) to multiplex the client and websocket traffic into the same connection. Having all the traffic go through the same address makes routing a client to its backend much easier (since it can just use its `document.location.host`). +QuakeKube also uses a cool trick with [cmux](https://github.com/soheilhy/cmux) to multiplex the client and websocket traffic into the same connection. Having all the traffic go through the same address makes routing a client to its backend much easier (since it can just use its `document.location.host`). ## Quake 3 demo EULA diff --git a/flake.nix b/flake.nix index 05d9429..ebb6ada 100644 --- a/flake.nix +++ b/flake.nix @@ -33,15 +33,48 @@ inherit system; }; + # The current default sdk for macOS fails to compile go projects, so we use a newer one for now. # This has no effect on other platforms. callPackage = pkgs.darwin.apple_sdk_11_0.callPackage or pkgs.callPackage; in rec { + packages.grpc_health_probe = pkgs.buildGoModule rec { + pname = "grpc-health-probe"; + version = "0.4.24"; + + src = pkgs.fetchFromGitHub { + owner = "grpc-ecosystem"; + repo = "grpc-health-probe"; + rev = "v${version}"; + sha256 = "sha256-OZ6vfRYO75kaDVrs/HTZCAPuJyoXOk/p4t85JrLrPwQ="; + }; + + ldflags = [ + "-s" + "-w" + "-X main.versionTag=v${version}" + ]; + + vendorHash = "sha256-4EJBhdHIRLMHCHThwBItF8ZWVJwU+/enq0AkRcP2Wk4="; + + nativeCheckInputs = with pkgs; [ + ]; + + checkPhase = ''''; + + meta = with pkgs.lib; { + description = "A command-line tool to perform health-checks for gRPC applications in Kubernetes and elsewhere"; + homepage = "https://github.com/grpc-ecosystem/grpc-health-probe"; + license = licenses.asl20; + mainProgram = "grpc-health-probe"; + }; + }; packages.default = pkgs.buildEnv { name = "quake-kube"; paths = with pkgs; [ packages.q3 + packages.grpc_health_probe ioquake3 ]; }; @@ -60,6 +93,7 @@ created = "now"; contents = [ packages.default + packages.grpc_health_probe pkgs.ioquake3 ]; config.Cmd = [ "${packages.default}/bin/q3" ]; @@ -85,6 +119,7 @@ terraform ioquake3 skopeo + packages.grpc_health_probe ]; }; }) diff --git a/go.mod b/go.mod index edd77c9..1a5d1b1 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ replace k8s.io/apimachinery => k8s.io/apimachinery v0.29.1 require ( github.com/google/go-cmp v0.6.0 github.com/gorilla/websocket v1.5.1 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/labstack/echo/v4 v4.11.4 github.com/prometheus/client_golang v1.18.0 github.com/soheilhy/cmux v0.1.5 diff --git a/go.sum b/go.sum index e44c68f..14beaa6 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= +github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/gomod2nix.toml b/gomod2nix.toml index cf32155..450fd04 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -28,6 +28,9 @@ schema = 3 [mod."github.com/gorilla/websocket"] version = "v1.5.1" hash = "sha256-eHZ/U+eeE5tSgWc1jEDuBwtTRbXKP9fqP9zfW4Zw8T0=" + [mod."github.com/hako/durafmt"] + version = "v0.0.0-20210608085754-5c1018a4e16b" + hash = "sha256-/IILmdwBY3tib/KhopKX0H8zr13dg9Z2XgPJkS0QkjI=" [mod."github.com/inconshreveable/mousetrap"] version = "v1.1.0" hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" diff --git a/internal/quake/client/httpserver.go b/internal/quake/client/httpserver.go index f90d025..2a54f59 100644 --- a/internal/quake/client/httpserver.go +++ b/internal/quake/client/httpserver.go @@ -1,6 +1,7 @@ package client import ( + "context" "html/template" "io" "net" @@ -11,7 +12,6 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/soheilhy/cmux" quakenet "github.com/ChrisRx/quake-kube/pkg/quake/net" ) @@ -22,11 +22,13 @@ type Config struct { } type HTTPClientServer struct { + *echo.Echo + + ctx context.Context cfg *Config - e *echo.Echo } -func NewHTTPClientServer(cfg *Config) (*HTTPClientServer, error) { +func NewHTTPClientServer(ctx context.Context, cfg *Config) (*HTTPClientServer, error) { e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) @@ -66,6 +68,16 @@ func NewHTTPClientServer(cfg *Config) (*HTTPClientServer, error) { }) }) + e.GET("/health", func(c echo.Context) error { + if _, err := quakenet.SendCommandWithTimeout( + cfg.ServerAddr, + quakenet.GetStatusCommand, + 1*time.Second, + ); err != nil { + return c.JSON(http.StatusServiceUnavailable, err.Error()) + } + return c.JSON(http.StatusOK, "OK") + }) e.GET("/metrics", echo.WrapHandler(promhttp.Handler())) e.GET("/info", func(c echo.Context) error { @@ -101,25 +113,35 @@ func NewHTTPClientServer(cfg *Config) (*HTTPClientServer, error) { Transport: &HostHeaderTransport{RoundTripper: http.DefaultTransport, Host: csurl.Host}, })) return &HTTPClientServer{ - cfg: cfg, - e: e, + Echo: e, + ctx: ctx, + cfg: cfg, }, nil } -func (h *HTTPClientServer) Match() []cmux.Matcher { - return []cmux.Matcher{ - cmux.Any(), - } -} - func (h *HTTPClientServer) Serve(l net.Listener) error { s := &http.Server{ - Handler: h.e, + Handler: h, ReadTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute, MaxHeaderBytes: 1 << 20, } - return s.Serve(l) + + errch := make(chan error, 1) + go func() { + defer close(errch) + + if err := s.Serve(l); err != nil { + errch <- err + } + }() + + select { + case err := <-errch: + return err + case <-h.ctx.Done(): + return h.Shutdown(h.ctx) + } } type HostHeaderTransport struct { diff --git a/internal/quake/client/proxyserver.go b/internal/quake/client/proxyserver.go index 08a97dc..8e8a9ee 100644 --- a/internal/quake/client/proxyserver.go +++ b/internal/quake/client/proxyserver.go @@ -10,7 +10,6 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/soheilhy/cmux" ) var DefaultUpgrader = &websocket.Upgrader{ @@ -25,10 +24,11 @@ var DefaultUpgrader = &websocket.Upgrader{ type WebsocketUDPProxy struct { Upgrader *websocket.Upgrader + ctx context.Context addr net.Addr } -func NewProxy(addr string) (*WebsocketUDPProxy, error) { +func NewProxy(ctx context.Context, addr string) (*WebsocketUDPProxy, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err @@ -42,20 +42,29 @@ func NewProxy(addr string) (*WebsocketUDPProxy, error) { if err != nil { return nil, err } - return &WebsocketUDPProxy{addr: raddr}, nil -} - -func (w *WebsocketUDPProxy) Match() []cmux.Matcher { - return []cmux.Matcher{ - cmux.HTTP1HeaderField("Upgrade", "websocket"), - } + return &WebsocketUDPProxy{ctx: ctx, addr: raddr}, nil } func (w *WebsocketUDPProxy) Serve(l net.Listener) error { s := &http.Server{ Handler: w, } - return s.Serve(l) + + errch := make(chan error, 1) + go func() { + defer close(errch) + + if err := s.Serve(l); err != nil { + errch <- err + } + }() + + select { + case err := <-errch: + return err + case <-w.ctx.Done(): + return s.Close() + } } func (w *WebsocketUDPProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { diff --git a/internal/quake/content/httpserver.go b/internal/quake/content/httpserver.go index 28d91b4..a8e2d48 100644 --- a/internal/quake/content/httpserver.go +++ b/internal/quake/content/httpserver.go @@ -1,6 +1,7 @@ package content import ( + "context" "net" "net/http" "os" @@ -10,18 +11,18 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/soheilhy/cmux" contentutil "github.com/ChrisRx/quake-kube/internal/quake/content/util" ) type HTTPServer struct { - e *echo.Echo + *echo.Echo + ctx context.Context assetsDir string } -func NewHTTPContentServer(assetsDir string) *HTTPServer { +func NewHTTPContentServer(ctx context.Context, assetsDir string) *HTTPServer { e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) @@ -52,25 +53,35 @@ func NewHTTPContentServer(assetsDir string) *HTTPServer { return c.JSONPretty(http.StatusOK, maps, " ") }) return &HTTPServer{ - e: e, + Echo: e, + ctx: ctx, assetsDir: assetsDir, } } -func (h *HTTPServer) Match() []cmux.Matcher { - return []cmux.Matcher{ - cmux.Any(), - } -} - func (h *HTTPServer) Serve(l net.Listener) error { s := &http.Server{ - Handler: h.e, + Handler: h, ReadTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute, MaxHeaderBytes: 1 << 20, } - return s.Serve(l) + + errch := make(chan error, 1) + go func() { + defer close(errch) + + if err := s.Serve(l); err != nil { + errch <- err + } + }() + + select { + case err := <-errch: + return err + case <-h.ctx.Done(): + return h.Shutdown(h.ctx) + } } // trimAssetName returns a path string that has been prefixed with a crc32 diff --git a/internal/quake/content/rpcserver.go b/internal/quake/content/rpcserver.go index 1d2d949..ecb99fa 100644 --- a/internal/quake/content/rpcserver.go +++ b/internal/quake/content/rpcserver.go @@ -1,25 +1,77 @@ package content import ( + "context" + "log" "net" + "time" "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" contentapiv1 "github.com/ChrisRx/quake-kube/internal/quake/content/api/v1" contentapiv2 "github.com/ChrisRx/quake-kube/internal/quake/content/api/v2" + "github.com/ChrisRx/quake-kube/internal/run" + quakenet "github.com/ChrisRx/quake-kube/pkg/quake/net" ) type RPCServer struct { assetsDir string + + ctx context.Context + s *grpc.Server + + serverAddr string + health *health.Server +} + +func NewRPCServer(ctx context.Context, assetsDir, serverAddr string) *RPCServer { + r := &RPCServer{ + assetsDir: assetsDir, + serverAddr: serverAddr, + ctx: ctx, + s: grpc.NewServer(), + } + if r.serverAddr != "" { + r.health = health.NewServer() + } + return r } -func NewRPCServer(assetsDir string) *RPCServer { - return &RPCServer{assetsDir} +func (r *RPCServer) checkServerHealth() { + run.Until(func() { + if _, err := quakenet.SendCommandWithTimeout(r.serverAddr, quakenet.GetInfoCommand, 1*time.Second); err != nil { + log.Printf("server %q unhealthy: %v\n", r.serverAddr, err) + r.health.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) + return + } + r.health.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + }, r.ctx.Done(), 10*time.Second) } func (r *RPCServer) Serve(l net.Listener) error { - s := grpc.NewServer() - contentapiv1.RegisterAssetsServer(s, contentapiv1.NewAssetsService(r.assetsDir)) - contentapiv2.RegisterAssetsServer(s, contentapiv2.NewAssetsService(r.assetsDir)) - return s.Serve(l) + if r.health != nil { + r.health.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(r.s, r.health) + go r.checkServerHealth() + } + contentapiv1.RegisterAssetsServer(r.s, contentapiv1.NewAssetsService(r.assetsDir)) + contentapiv2.RegisterAssetsServer(r.s, contentapiv2.NewAssetsService(r.assetsDir)) + + errch := make(chan error, 1) + go func() { + defer close(errch) + + errch <- r.s.Serve(l) + }() + + select { + case <-r.ctx.Done(): + r.s.GracefulStop() + return r.ctx.Err() + case err := <-errch: + r.s.GracefulStop() + return err + } } diff --git a/internal/quake/content/util/files.go b/internal/quake/content/util/files.go index 07af2de..754e1f5 100644 --- a/internal/quake/content/util/files.go +++ b/internal/quake/content/util/files.go @@ -76,11 +76,12 @@ func DownloadAssets(u *url.URL, dir string) error { if err := os.WriteFile(path, data, 0644); err != nil { return err } + fmt.Printf("Downloaded %s\n", f.Name) // The demo and point releases are compressed gzip files and contain the // base pak files needed to play the Quake 3 Arena demo. if strings.HasPrefix(f.Name, "linuxq3ademo") || strings.HasPrefix(f.Name, "linuxq3apoint") { - if err := extractGzip(path, dir); err != nil { + if err := ExtractGzip(path, dir); err != nil { return err } } @@ -90,8 +91,8 @@ func DownloadAssets(u *url.URL, dir string) error { var gzipMagicHeader = []byte{'\x1f', '\x8b'} -// extractGzip -func extractGzip(path, dir string) error { +// ExtractGzip +func ExtractGzip(path, dir string) error { data, err := os.ReadFile(path) if err != nil { return err @@ -120,8 +121,8 @@ func extractGzip(path, dir string) error { if err != nil { return err } - if strings.HasSuffix(hdr.Name, ".pk3") { - fmt.Printf("Downloaded %s\n", hdr.Name) + if strings.HasSuffix(hdr.Name, ".pk3") || strings.HasSuffix(hdr.Name, ".txt") || hdr.Name == "README" { + fmt.Printf("Extracted %s\n", hdr.Name) data, err := io.ReadAll(tr) if err != nil { return err diff --git a/internal/quake/server/eula.go b/internal/quake/server/EULA.txt similarity index 99% rename from internal/quake/server/eula.go rename to internal/quake/server/EULA.txt index 4cd4c03..652cca0 100644 --- a/internal/quake/server/eula.go +++ b/internal/quake/server/EULA.txt @@ -1,6 +1,4 @@ -package server - -const Q3DemoEULA = `LIMITED USE SOFTWARE LICENSE AGREEMENT +LIMITED USE SOFTWARE LICENSE AGREEMENT This Limited Use Software License Agreement (the "Agreement") is a legal agreement between you, the end-user, and Id Software, Inc. ("ID"). BY @@ -175,4 +173,3 @@ LIABILITIES OF THE PARTIES HERETO. THIS AGREEMENT SUPERSEDES ALL PRIOR ORAL AGREEMENTS, PROPOSALS OR UNDERSTANDINGS, AND ANY OTHER COMMUNICATIONS BETWEEN ID AND YOU RELATING TO THE SUBJECT MATTER OF THIS AGREEMENT. -` diff --git a/internal/quake/server/config.go b/internal/quake/server/config.go index 755980d..a4b5a1f 100644 --- a/internal/quake/server/config.go +++ b/internal/quake/server/config.go @@ -3,14 +3,58 @@ package server import ( "bytes" "fmt" + "os" "reflect" "strconv" "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" ) +func ReadConfigFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := Default() + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return cfg, nil +} + +type Config struct { + FragLimit int `name:"fraglimit"` + TimeLimit metav1.Duration `name:"timelimit"` + + BotConfig `json:"bot"` + GameConfig `json:"game"` + FileServerConfig `json:"fs"` + ServerConfig `json:"server"` + Commands []string `json:"commands"` + + Maps +} + +type BotConfig struct { + MinPlayers int `name:"bot_minplayers"` + NoChat bool `name:"bot_nochat"` +} + +type GameConfig struct { + ForceRespawn bool `name:"g_forcerespawn"` + GameType GameType `name:"g_gametype" json:"type"` + Inactivity metav1.Duration `name:"g_inactivity"` + Log string `name:"g_log"` + MOTD string `name:"g_motd"` + Password string `name:"g_password"` + QuadFactor int `name:"g_quadfactor"` + SinglePlayerSkill int `name:"g_spSkill"` + WeaponRespawn int `name:"g_weaponrespawn"` +} + type GameType int const ( @@ -56,36 +100,6 @@ func (gt *GameType) UnmarshalText(data []byte) error { return nil } -type Config struct { - FragLimit int `name:"fraglimit"` - TimeLimit metav1.Duration `name:"timelimit"` - - BotConfig `json:"bot"` - GameConfig `json:"game"` - FileServerConfig `json:"fs"` - ServerConfig `json:"server"` - Commands []string `json:"commands"` - - Maps -} - -type BotConfig struct { - MinPlayers int `name:"bot_minplayers"` - NoChat bool `name:"bot_nochat"` -} - -type GameConfig struct { - ForceRespawn bool `name:"g_forcerespawn"` - GameType GameType `name:"g_gametype" json:"type"` - Inactivity metav1.Duration `name:"g_inactivity"` - Log string `name:"g_log"` - MOTD string `name:"g_motd"` - Password string `name:"g_password"` - QuadFactor int `name:"g_quadfactor"` - SinglePlayerSkill int `name:"g_spSkill"` - WeaponRespawn int `name:"g_weaponrespawn"` -} - type FileServerConfig struct { // allows people to base mods upon mods syntax to follow BaseGame string `name:"fs_basegame"` diff --git a/internal/quake/server/gamefiles.go b/internal/quake/server/gamefiles.go new file mode 100644 index 0000000..8d09b90 --- /dev/null +++ b/internal/quake/server/gamefiles.go @@ -0,0 +1,50 @@ +package server + +import ( + "archive/tar" + "bytes" + _ "embed" + "io" + "log" + "os" + "path/filepath" +) + +//go:embed EULA.txt +var Q3DemoEULA []byte + +//go:embed gamefiles.tar +var gamefiles []byte + +func ExtractGameFiles(dir string) error { + tr := tar.NewReader(bytes.NewReader(gamefiles)) + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + switch hdr.Typeflag { + case tar.TypeReg: + path := filepath.Join(dir, hdr.Name) + if _, err := os.Stat(path); !os.IsNotExist(err) { + continue + } + log.Printf("Extracting: %s\n", path) + data, err := io.ReadAll(tr) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/quake/server/gamefiles.tar b/internal/quake/server/gamefiles.tar new file mode 100644 index 0000000..58b13c5 Binary files /dev/null and b/internal/quake/server/gamefiles.tar differ diff --git a/internal/quake/server/server.go b/internal/quake/server/server.go index c5348c1..edb90f3 100644 --- a/internal/quake/server/server.go +++ b/internal/quake/server/server.go @@ -2,16 +2,20 @@ package server import ( "context" + _ "embed" + "fmt" "log" "net" "os" "path/filepath" + "strings" "time" + "github.com/hako/durafmt" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "sigs.k8s.io/yaml" + "github.com/ChrisRx/quake-kube/internal/run" "github.com/ChrisRx/quake-kube/internal/util/exec" quakenet "github.com/ChrisRx/quake-kube/pkg/quake/net" ) @@ -39,10 +43,11 @@ var ( ) type Server struct { + Addr string + ConfigFile string Dir string WatchInterval time.Duration - ConfigFile string - Addr string + ShutdownDelay time.Duration } func (s *Server) Start(ctx context.Context) error { @@ -54,7 +59,10 @@ func (s *Server) Start(ctx context.Context) error { return err } args := []string{ - "+set", "dedicated", "1", + "+set", "dedicated", "2", + "+set", "sv_master1", "", // master.ioquake3.org + "+set", "sv_master2", "", // master.quake3arena..com + "+set", "sv_master3", "", // localhost:27950 "+set", "net_ip", host, "+set", "net_port", port, "+set", "fs_homepath", s.Dir, @@ -64,7 +72,7 @@ func (s *Server) Start(ctx context.Context) error { "+set", "com_gamename", "Quake3Arena", "+exec", "server.cfg", } - cmd := exec.CommandContext(ctx, "ioq3ded", args...) + cmd := exec.CommandContext(context.Background(), "ioq3ded", args...) cmd.Dir = s.Dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -131,6 +139,14 @@ func (s *Server) Start(ctx context.Context) error { return err } + defer func() { + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + log.Printf("couldn't kill process: %v\n", err) + } + } + }() + for { select { case <-ch: @@ -147,21 +163,70 @@ func (s *Server) Start(ctx context.Context) error { } }() case <-ctx.Done(): + s.GracefulStop() return ctx.Err() } } } -func (s *Server) reload() error { - data, err := os.ReadFile(s.ConfigFile) +func (s *Server) GracefulStop() { + if s.ShutdownDelay == 0 { + return + } + cfg, err := ReadConfigFromFile(s.ConfigFile) if err != nil { - return err + log.Println(err) + return } - cfg := Default() - if err := yaml.Unmarshal(data, &cfg); err != nil { + msg := fmt.Sprintf("say SERVER WILL BE SHUTTING DOWN IN %s", strings.ToUpper(durafmt.Parse(s.ShutdownDelay).String())) + if _, err := quakenet.SendServerCommand(s.Addr, cfg.ServerConfig.Password, msg); err != nil { + log.Printf("say: %v\n", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), s.ShutdownDelay) + defer cancel() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + countdown := int(s.ShutdownDelay.Seconds()) - 1 + for { + select { + case <-ticker.C: + countdown-- + if countdown == 0 { + return + } + if _, err := quakenet.SendServerCommand(s.Addr, cfg.ServerConfig.Password, fmt.Sprintf("say %d\n", countdown)); err != nil { + log.Printf("countdown: %v\n", err) + } + case <-ctx.Done(): + if _, err := quakenet.SendServerCommand(s.Addr, cfg.ServerConfig.Password, "say GOODBYE"); err != nil { + log.Printf("goodbye: %v\n", err) + } + status, err := quakenet.GetStatus(s.Addr) + if err != nil { + log.Printf("getstatus: %v\n", err) + return + } + for _, player := range status.Players { + if _, err := quakenet.SendServerCommand(s.Addr, cfg.ServerConfig.Password, fmt.Sprintf("kick %s", player.Name)); err != nil { + log.Printf("kick: %v\n", err) + } + } + time.Sleep(1 * time.Second) + return + } + } +} + +func (s *Server) reload() error { + cfg, err := ReadConfigFromFile(s.ConfigFile) + if err != nil { return err } - data, err = cfg.Marshal() + data, err := cfg.Marshal() if err != nil { return err } @@ -179,23 +244,13 @@ func (s *Server) watch(ctx context.Context) (<-chan struct{}, error) { ch := make(chan struct{}) - go func() { - ticker := time.NewTicker(s.WatchInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if fi, err := os.Stat(s.ConfigFile); err == nil { - if fi.ModTime().After(cur.ModTime()) { - ch <- struct{}{} - } - cur = fi - } - case <-ctx.Done(): - return + go run.Until(func() { + if fi, err := os.Stat(s.ConfigFile); err == nil { + if fi.ModTime().After(cur.ModTime()) { + ch <- struct{}{} } + cur = fi } - }() + }, ctx.Done(), s.WatchInterval) return ch, nil } diff --git a/internal/run/run.go b/internal/run/run.go new file mode 100644 index 0000000..921c430 --- /dev/null +++ b/internal/run/run.go @@ -0,0 +1,38 @@ +package run + +import ( + "errors" + "log" + "time" +) + +func Until(do func(), stop <-chan struct{}, interval time.Duration) { + if err := runUntil(func() error { + do() + return nil + }, stop, interval); err != nil { + log.Println(err) + } +} + +func UntilE(do func() error, stop <-chan struct{}, interval time.Duration) error { + return runUntil(do, stop, interval) +} + +var ErrStopped = errors.New("stopped") + +func runUntil(do func() error, stop <-chan struct{}, interval time.Duration) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := do(); err != nil { + return err + } + case <-stop: + return ErrStopped + } + } +} diff --git a/internal/util/exec/exec.go b/internal/util/exec/exec.go index c1369b2..9f0069c 100644 --- a/internal/util/exec/exec.go +++ b/internal/util/exec/exec.go @@ -3,6 +3,7 @@ package exec import ( "context" "os/exec" + "syscall" ) type Cmd struct { @@ -16,6 +17,7 @@ func (cmd *Cmd) Restart(ctx context.Context) error { } } newCmd := exec.CommandContext(ctx, cmd.Args[0], cmd.Args[1:]...) + newCmd.SysProcAttr = cmd.SysProcAttr newCmd.Dir = cmd.Dir newCmd.Env = cmd.Env newCmd.Stdin = cmd.Stdin @@ -26,5 +28,9 @@ func (cmd *Cmd) Restart(ctx context.Context) error { } func CommandContext(ctx context.Context, name string, args ...string) *Cmd { - return &Cmd{Cmd: exec.CommandContext(ctx, name, args...)} + cmd := exec.CommandContext(ctx, name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + return &Cmd{Cmd: cmd} } diff --git a/pkg/mux/mux.go b/pkg/mux/mux.go index 25d4e12..2523d00 100644 --- a/pkg/mux/mux.go +++ b/pkg/mux/mux.go @@ -4,7 +4,9 @@ import ( "bytes" "fmt" "io" + "log" "net" + "sync" "github.com/soheilhy/cmux" ) @@ -19,6 +21,7 @@ type Mux struct { cmux.CMux conns []*registerConn + wg sync.WaitGroup } // New @@ -39,53 +42,77 @@ func (m *Mux) Register(conn Connection) *registerConn { return r } +func (m *Mux) startConns() error { + for _, c := range m.conns { + if c.l == nil { + return fmt.Errorf("match not defined for connection: %T\n", c.conn) + } + m.wg.Add(1) + go func(c *registerConn) { + defer m.wg.Done() + + if err := c.conn.Serve(c.l); err != nil && err != cmux.ErrListenerClosed { + log.Println(err) + } + }(c) + } + return nil +} + // Serve func (m *Mux) Serve() error { - for _, conn := range m.conns { - if !conn.started { - panic(fmt.Errorf("match not defined for connection: %T\n", conn.conn)) - } + if err := m.startConns(); err != nil { + return err } return m.CMux.Serve() } +func (m *Mux) ServeAndWait() error { + if err := m.startConns(); err != nil { + return err + } + + go func() { + if err := m.CMux.Serve(); err != nil { + log.Printf("Serve: %v\n", err) + } + }() + + m.wg.Wait() + return nil +} + type registerConn struct { *Mux - conn Connection - started bool + conn Connection + l net.Listener } func (r *registerConn) Match(matches ...any) { if len(matches) == 0 { return } + if r.l != nil { + panic(fmt.Errorf("cannot call Match multiple times")) + } - var l net.Listener switch matches[0].(type) { case cmux.Matcher: ms := make([]cmux.Matcher, len(matches)) for i := range matches { ms[i] = matches[i].(cmux.Matcher) } - l = r.Mux.Match(ms...) + r.l = r.Mux.Match(ms...) case cmux.MatchWriter: ms := make([]cmux.MatchWriter, len(matches)) for i := range matches { ms[i] = matches[i].(cmux.MatchWriter) } - l = r.MatchWithWriters(ms...) + r.l = r.MatchWithWriters(ms...) default: panic(fmt.Errorf("expected cmux.Matcher | cmux.MatchWriter, received %T", matches[0])) } - - go func() { - if err := r.conn.Serve(l); err != cmux.ErrListenerClosed { - panic(err) - } - }() - - r.started = true } func (r *registerConn) Any() { diff --git a/pkg/quake/net/net.go b/pkg/quake/net/net.go index 3bbd6c2..55cd27d 100644 --- a/pkg/quake/net/net.go +++ b/pkg/quake/net/net.go @@ -15,6 +15,13 @@ const ( ) func SendCommand(addr, cmd string) ([]byte, error) { + return SendCommandWithTimeout(addr, cmd, 5*time.Second) +} + +func SendCommandWithTimeout(addr, cmd string, timeout time.Duration) ([]byte, error) { + if timeout == 0 { + timeout = 5 * time.Second + } raddr, err := net.ResolveUDPAddr("udp4", addr) if err != nil { return nil, err @@ -26,20 +33,23 @@ func SendCommand(addr, cmd string) ([]byte, error) { defer conn.Close() buffer := make([]byte, 1024*1024) - if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { return nil, err } - n, err := conn.WriteTo([]byte(fmt.Sprintf("%s%s", OutOfBandHeader, cmd)), raddr) - if err != nil { + if _, err := conn.WriteTo([]byte(fmt.Sprintf("%s%s", OutOfBandHeader, cmd)), raddr); err != nil { return nil, err } - n, _, err = conn.ReadFrom(buffer) + n, _, err := conn.ReadFrom(buffer) if err != nil { return nil, err } return buffer[:n], nil } +func SendServerCommand(addr, password, cmd string) ([]byte, error) { + return SendCommand(addr, fmt.Sprintf("rcon %s %s", password, cmd)) +} + func parseMap(data []byte) map[string]string { if i := bytes.Index(data, []byte("\n")); i >= 0 { data = data[i+1:]