├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── release-build.yml │ ├── release-sdk.yml │ └── unstable-build.yml ├── .gitignore ├── .golangci.yml ├── .tool-versions ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── apis ├── factorioapi │ ├── v1 │ │ ├── command.pb.go │ │ ├── command.pb.gw.go │ │ ├── command.proto │ │ ├── command_grpc.pb.go │ │ ├── v1.pb.go │ │ ├── v1.proto │ │ ├── v1.swagger.json │ │ ├── v1.swagger.v3.json │ │ ├── v1.swagger.v3.yaml │ │ └── v1_swagger.go │ └── v2 │ │ ├── command.pb.go │ │ ├── command.pb.gw.go │ │ ├── command.proto │ │ ├── command_grpc.pb.go │ │ ├── v2.pb.go │ │ ├── v2.proto │ │ ├── v2.swagger.json │ │ ├── v2.swagger.v3.json │ │ └── v2.swagger.v3.yaml ├── jsonapi │ ├── jsonapi.pb.go │ └── jsonapi.proto └── sdk │ └── typescript │ ├── README.md │ ├── eslint.config.js │ ├── openapi-ts.config.ts │ ├── package.json │ ├── pnpm-lock.yaml │ ├── pnpm-workspace.yaml │ ├── src │ ├── client │ │ ├── index.ts │ │ ├── sdk.gen.ts │ │ └── types.gen.ts │ └── index.ts │ └── tsconfig.json ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── cmd ├── api-server │ └── main.go └── tools │ └── openapiv2conv │ └── main.go ├── cspell.config.yaml ├── docker-compose.yaml ├── docs └── demo.mp4 ├── go.mod ├── go.sum ├── hack ├── proto-export └── proto-gen ├── internal ├── configs │ ├── common.go │ └── configs.go ├── grpc │ ├── servers │ │ ├── factorioapi │ │ │ └── apiserver │ │ │ │ ├── grpc_gateway.go │ │ │ │ └── grpc_server.go │ │ ├── interceptors │ │ │ ├── authorization.go │ │ │ ├── cookie.go │ │ │ ├── error_handler.go │ │ │ ├── panic.go │ │ │ └── request_path.go │ │ ├── middlewares │ │ │ ├── health.go │ │ │ ├── response_log.go │ │ │ ├── route_not_found.go │ │ │ ├── scalar.go │ │ │ └── static.go │ │ └── servers.go │ └── services │ │ ├── factorioapi │ │ ├── factorioapi.go │ │ ├── v1 │ │ │ └── console │ │ │ │ └── console.go │ │ └── v2 │ │ │ └── console │ │ │ ├── console.go │ │ │ └── console_test.go │ │ └── services.go ├── libs │ ├── libs.go │ ├── logger.go │ └── tracing.go ├── meta │ └── meta.go └── rcon │ ├── fake │ └── rcon.go │ └── rcon.go ├── pkg ├── apierrors │ ├── apierrors.go │ └── errors.go ├── grpc │ ├── gateway.go │ └── register.go ├── http │ └── trace.go └── utils │ ├── factorio.go │ └── factorio_test.go └── tools └── tools.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nekomeowww 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":dependencyDashboard", 6 | ":semanticPrefixFixDepsChoreOthers", 7 | ":prHourlyLimitNone", 8 | ":prConcurrentLimitNone", 9 | ":ignoreModulesAndTests", 10 | "schedule:monthly", 11 | "group:allNonMajor", 12 | "replacements:all", 13 | "workarounds:all" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - "**/*.md" 12 | 13 | jobs: 14 | buildtest: 15 | name: Build Test 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: "^1.24" 25 | cache: true 26 | 27 | # Build test 28 | - name: Test Build 29 | run: go build -a -o "release/factorio-rcon-api" "github.com/nekomeowww/factorio-rcon-api/v2/cmd/api-server" 30 | 31 | lint: 32 | name: Lint 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v4 37 | 38 | - name: Setup Go 39 | uses: actions/setup-go@v5 40 | with: 41 | go-version: "^1.24" 42 | cache: true 43 | 44 | - name: Lint 45 | uses: golangci/golangci-lint-action@v8.0.0 46 | with: 47 | # Optional: golangci-lint command line arguments. 48 | args: "--timeout=10m" 49 | 50 | unittest: 51 | name: Unit Test 52 | runs-on: ubuntu-latest 53 | 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | 58 | - name: Setup Go 59 | uses: actions/setup-go@v5 60 | with: 61 | go-version: "^1.24" 62 | cache: true 63 | 64 | - name: Unit tests 65 | run: | 66 | go test ./... -coverprofile=coverage.out -covermode=atomic -p=1 67 | go tool cover -func coverage.out 68 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yml: -------------------------------------------------------------------------------- 1 | name: Release Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | ghcr_build: 11 | name: Build for GitHub Container Registry 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Fetch version 21 | id: version 22 | run: | 23 | export LAST_TAGGED_COMMIT=$(git rev-list --tags --max-count=1) 24 | export LAST_TAG=$(git describe --tags $LAST_TAGGED_COMMIT) 25 | echo "version=${LAST_TAG#v}" >> $GITHUB_OUTPUT 26 | 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | with: 33 | platforms: linux/amd64,linux/arm64,linux/arm64/v8 34 | 35 | - name: Sign in to GitHub Container Registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.repository_owner }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: Create image tags 43 | id: dockerinfo 44 | run: | 45 | echo "taglatest=ghcr.io/${{ github.repository }}:latest" >> $GITHUB_OUTPUT 46 | echo "tag=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }}" >> $GITHUB_OUTPUT 47 | 48 | - name: Build and Push 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: ./ 52 | file: ./Dockerfile 53 | push: true 54 | platforms: linux/amd64,linux/arm64,linux/arm64/v8 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | tags: | 58 | ${{ steps.dockerinfo.outputs.taglatest }} 59 | ${{ steps.dockerinfo.outputs.tag }} 60 | -------------------------------------------------------------------------------- /.github/workflows/release-sdk.yml: -------------------------------------------------------------------------------- 1 | name: Release SDK 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | STORE_PATH: "" 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install Node.js 23.x 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 23.x 23 | # registry-url required. Learn more at 24 | # https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 25 | registry-url: "https://registry.npmjs.org" 26 | 27 | - uses: pnpm/action-setup@v4 28 | name: Install pnpm 29 | with: 30 | run_install: false 31 | package_json_file: ./apis/sdk/typescript/package.json 32 | 33 | - name: Get pnpm store directory 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 37 | 38 | - uses: actions/cache@v4 39 | name: Setup pnpm cache 40 | with: 41 | path: ${{ env.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | 46 | - name: Install dependencies 47 | run: pnpm install --frozen-lockfile 48 | 49 | - name: Packages build 50 | run: pnpm run build 51 | 52 | - name: Packages publish 53 | run: pnpm run packages:publish 54 | env: 55 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 56 | -------------------------------------------------------------------------------- /.github/workflows/unstable-build.yml: -------------------------------------------------------------------------------- 1 | name: Unstable Build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | ghcr_build: 8 | name: Build for GitHub Container Registry 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up QEMU 18 | uses: docker/setup-qemu-action@v3 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | with: 23 | platforms: linux/amd64,linux/arm64,linux/arm64/v8 24 | 25 | - name: Sign in to GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Create image tags 33 | id: dockerinfo 34 | run: | 35 | echo "tagunstable=ghcr.io/${{ github.repository }}:unstable" >> $GITHUB_OUTPUT 36 | 37 | - name: Build and Push 38 | uses: docker/build-push-action@v6 39 | with: 40 | context: ./ 41 | file: ./Dockerfile 42 | push: true 43 | platforms: linux/amd64,linux/arm64,linux/arm64/v8 44 | cache-from: type=gha 45 | cache-to: type=gha,mode=max 46 | tags: | 47 | ${{ steps.dockerinfo.outputs.tagunstable }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea/ 3 | 4 | # Created by https://www.gitignore.io/api/visualstudiocode 5 | # Edit at https://www.gitignore.io/?templates=visualstudiocode 6 | 7 | ### VisualStudioCode ### 8 | # Maybe .vscode/**/* instead - see comments 9 | .vscode/* 10 | !.vscode/settings.json 11 | !.vscode/tasks.json 12 | !.vscode/launch.json 13 | !.vscode/extensions.json 14 | 15 | ### VisualStudioCode Patch ### 16 | # Ignore all local history of files 17 | **/.history 18 | 19 | # End of https://www.gitignore.io/api/visualstudiocode 20 | 21 | # Build / Release 22 | *.exe 23 | *.exe~ 24 | *.dll 25 | *.so 26 | *.dylib 27 | *.db 28 | *.bin 29 | *.tar.gz 30 | /release/ 31 | **/dist 32 | 33 | # Runtime / Compile Temporary Assets 34 | vendor/ 35 | logs/ 36 | 37 | # Credentials 38 | cert*/ 39 | *.pem 40 | *.crt 41 | *.cer 42 | *.key 43 | *.p12 44 | 45 | # Test binary, build with `go test -c` 46 | *.test 47 | cover* 48 | coverage* 49 | 50 | # Output of the go coverage tool, specifically when used with LiteIDE 51 | *.out 52 | 53 | # macOS 54 | .DS_Store 55 | 56 | # Configurations 57 | config.yaml 58 | config.yml 59 | .env 60 | 61 | # Local Configuration 62 | config.local.yaml 63 | 64 | # Temporary 65 | temp/ 66 | .cache 67 | .temp 68 | 69 | # Local pgSQL db 70 | .postgres/ 71 | 72 | # Local Factorio 73 | .factorio/ 74 | factorio/ 75 | 76 | # Debug 77 | __debug* 78 | 79 | # Local factorio dir 80 | .factorio/ 81 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - containedctx 6 | - contextcheck 7 | - cyclop 8 | - depguard 9 | - err113 10 | - exhaustruct 11 | - funlen 12 | - gochecknoglobals 13 | - gochecknoinits 14 | - gocognit 15 | - gocyclo 16 | - godot 17 | - godox 18 | - ireturn 19 | - lll 20 | - maintidx 21 | - nilnil 22 | - nlreturn 23 | - paralleltest 24 | - tagalign 25 | - tagliatelle 26 | - testpackage 27 | - varnamelen 28 | - wrapcheck 29 | settings: 30 | dupl: 31 | threshold: 600 32 | gocritic: 33 | disabled-checks: 34 | - ifElseChain 35 | gosec: 36 | excludes: 37 | - G115 38 | mnd: 39 | ignored-files: 40 | - examples/.* 41 | ignored-functions: 42 | - context.WithTimeout 43 | - strconv.ParseComplex 44 | nestif: 45 | min-complexity: 9 46 | revive: 47 | rules: 48 | - name: blank-imports 49 | disabled: true 50 | wsl: 51 | strict-append: false 52 | allow-assign-and-call: false 53 | allow-trailing-comment: true 54 | allow-separated-leading-comment: true 55 | allow-cuddle-declarations: true 56 | exclusions: 57 | generated: lax 58 | presets: 59 | - comments 60 | - common-false-positives 61 | - legacy 62 | - std-error-handling 63 | rules: 64 | - linters: 65 | - perfsprint 66 | path: _test\.go 67 | - path: (.+)\.go$ 68 | text: if statements should only be cuddled with assignments 69 | - path: (.+)\.go$ 70 | text: if statements should only be cuddled with assignments used in the if statement itself 71 | - path: (.+)\.go$ 72 | text: assignments should only be cuddled with other assignments 73 | paths: 74 | - apis 75 | - api 76 | - third_party$ 77 | - builtin$ 78 | - examples$ 79 | formatters: 80 | enable: 81 | - gofmt 82 | - goimports 83 | exclusions: 84 | generated: lax 85 | paths: 86 | - apis 87 | - api 88 | - third_party$ 89 | - builtin$ 90 | - examples$ 91 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.24.3 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "mikestead.dotenv", 5 | "EditorConfig.EditorConfig", 6 | "yzhang.markdown-all-in-one", 7 | "redhat.vscode-yaml", 8 | "golang.go", 9 | "bufbuild.vscode-buf", 10 | "zxh404.vscode-proto3" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch factorio-rcon-api/api-server", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/api-server/main.go", 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.useLanguageServer": true, 3 | // "go.buildOnSave": "workspace", 4 | "go.lintOnSave": "package", 5 | "go.vetOnSave": "workspace", 6 | "go.coverOnSave": false, 7 | "go.lintTool": "golangci-lint", 8 | "go.lintFlags": [ 9 | "--config=${workspaceFolder}/.golangci.yml", 10 | ], 11 | "go.inferGopath": true, 12 | "go.alternateTools": {}, 13 | "go.coverOnSingleTest": true, 14 | "go.testTimeout": "900s", 15 | "go.testFlags": [ 16 | "-v", 17 | "-count=1" 18 | ], 19 | "go.toolsManagement.autoUpdate": true, 20 | "go.coverOnSingleTestFile": true, 21 | "gopls": { 22 | "build.buildFlags": [], 23 | "ui.completion.usePlaceholders": true, 24 | "ui.semanticTokens": true 25 | }, 26 | "[go]": { 27 | "editor.insertSpaces": false, 28 | "editor.formatOnSave": true, 29 | "editor.codeActionsOnSave": { 30 | "source.organizeImports": "always" 31 | }, 32 | }, 33 | // Consider integration with Buf · Issue #138 · zxh0/vscode-proto3 34 | // https://github.com/zxh0/vscode-proto3/issues/138 35 | "protoc": { 36 | "options": [ 37 | "--proto_path=${workspaceFolder}/.temp/cache/buf.build/vendor/proto", 38 | ] 39 | }, 40 | "cSpell.words": [ 41 | "mixtral" 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- builder --- 2 | FROM golang:1.24 as builder 3 | 4 | ARG BUILD_VERSION 5 | ARG BUILD_LAST_COMMIT 6 | 7 | RUN mkdir /app 8 | RUN mkdir /app/api-server 9 | 10 | WORKDIR /app/api-server 11 | 12 | COPY go.mod /app/api-server/go.mod 13 | COPY go.sum /app/api-server/go.sum 14 | 15 | RUN go env 16 | RUN go env -w CGO_ENABLED=0 17 | RUN go mod download 18 | 19 | COPY . /app/api-server 20 | 21 | RUN go build \ 22 | -a \ 23 | -o "release/api-server" \ 24 | -ldflags " -X './internal/meta.Version=$BUILD_VERSION' -X './internal/meta.LastCommit=$BUILD_LAST_COMMIT'" \ 25 | "./cmd/api-server" 26 | 27 | # --- runner --- 28 | FROM debian as runner 29 | 30 | RUN apt update && apt upgrade -y && apt install -y ca-certificates curl && update-ca-certificates 31 | RUN apt install -y lsof net-tools iproute2 telnet procps 32 | 33 | COPY --from=builder /app/api-server/release/api-server /app/api-server/release/api-server 34 | 35 | RUN mkdir -p /usr/local/bin 36 | RUN ln -s /app/api-server/release/api-server /usr/local/bin/factorio-rcon-api 37 | 38 | WORKDIR /app/api-server 39 | RUN mkdir -p /app/api-server/logs 40 | 41 | EXPOSE 24180 42 | EXPOSE 24181 43 | 44 | CMD [ "/usr/local/bin/factorio-rcon-api" ] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Neko Ayaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Factorio RCON API 2 | 3 | > [Live API Docs](https://factorio-rcon-api.ayaka.io/apis/docs/v2) 4 | 5 | 🏭 Fully implemented wrapper for Factorio headless server console as RESTful and gRPC for easier management through APIs 6 | 7 | ## Compatible matrix 8 | 9 | | Factorio RCON API | Factorio Server | 10 | |-------------------|-----------------| 11 | | `/api/v1` | 1.x | 12 | | `/api/v2` | 2.x | 13 | 14 | ## Features 15 | 16 | - ✅ 100% type safe, no extra type conversion needed 17 | - ↔️ Out of the box RESTful and gRPC support 18 | - 🎺 Native RCON protocol 19 | - 📖 Fully API Documented 20 | 21 | ## Supported Commands 22 | 23 | - [x] Raw command 24 | - [x] Message 25 | - [ ] `/alerts` 26 | - [ ] `/enable-research-queue` 27 | - [ ] `/mute-programmable-speaker` 28 | - [ ] `/perf-avg-frames` 29 | - [ ] `/permissions` 30 | - [ ] `/reset-tips` 31 | - [x] `/evolution` 32 | - [x] `/seed` 33 | - [x] `/time` 34 | - [ ] `/toggle-action-logging` 35 | - [ ] `/toggle-heavy-mode` 36 | - [ ] `/unlock-shortcut-bar` 37 | - [ ] `/unlock-tips` 38 | - [x] `/version` 39 | - [x] `/admins` 40 | - [x] `/ban` 41 | - [x] `/bans` 42 | - [ ] `/config` 43 | - [ ] `/delete-blueprint-library` 44 | - [x] `/demote` 45 | - [x] `/ignore` 46 | - [x] `/kick` 47 | - [x] `/mute` 48 | - [x] `/mutes` 49 | - [x] `/promote` 50 | - [x] `/purge` 51 | - [x] `/server-save` 52 | - [x] `/unban` 53 | - [x] `/unignore` 54 | - [x] `/unmute` 55 | - [x] `/whisper` 56 | - [x] `/whitelist` 57 | - [ ] `/cheat` 58 | - [x] `/command` / `/c` 59 | - [ ] `/measured-command` 60 | - [ ] `/silent-command` 61 | 62 | ## Usage 63 | 64 | > [!CAUTION] 65 | > **Before you proceed - Security concerns** 66 | > 67 | > This API implementation will allow any of the users that can ACCESS the endpoint to control over & perform admin operations to Factorio server it connected to, while API server doesn't come out with any security features (e.g. Basic Auth or Authorization header based authentication). 68 | > 69 | > You are responsible for securing your Factorio server and the API server by either: 70 | > 71 | > - use Nginx/Caddy or similar servers for authentication 72 | > - use Cloudflare Tunnel or TailScale for secure tunneling 73 | > - use this project only for internal communication (e.g. Bots, API wrappers, admin UI, etc.) 74 | > 75 | > Otherwise, we are not responsible for any data loss, security breaches, save corruptions, or any other issues caused by the outside attackers. 76 | 77 | ### Pull the image 78 | 79 | ```shell 80 | docker pull ghcr.io/nekomeowww/factorio-rcon-api 81 | ``` 82 | 83 | ### Setup Factorio servers 84 | 85 | > [!NOTE] 86 | > **About RCON** 87 | > 88 | > [RCON](https://wiki.vg/RCON) is a TCP/IP-based protocol that allows server administrators to remotely execute commands, developed by Valve for Source Engine. It is widely used in game servers, including Factorio, Minecraft. 89 | 90 | > [!CAUTION] 91 | > **Before you proceed - Security concerns** 92 | > 93 | > Since RCON protocol will give administrators access to the server console, it is recommended to: 94 | > 95 | > - do not expose the RCON port to the public internet 96 | > - use RCON with password authentication 97 | > - rotate the password once a month to prevent attackers from accessing the server 98 | 99 | When bootstraping the server, you need to specify the RCON port and password for the server to listen to with 100 | 101 | - `--rcon-port` for the port number 102 | - `--rcon-password` for the password 103 | 104 | > Documentation of these parameters can be found at [Command line parameters - Factorio Wiki](https://wiki.factorio.com/Command_line_parameters) 105 | 106 | The command may look like this: 107 | 108 | ```shell 109 | ./factorio \ 110 | --start-server /path/to/saves/my-save.zip \ 111 | --rcon-port 27015 \ 112 | --rcon-password 123456 113 | ``` 114 | 115 | Or on macOS: 116 | 117 | ```shell 118 | ~/Library/Application\ Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio \ 119 | --start-server /path/to/saves/my-save.zip \ 120 | --rcon-port 27015 \ 121 | --rcon-password 123456 122 | ``` 123 | 124 | Once you are ready, go to the next step to start the API server. 125 | 126 | ### Factorio server ran with Docker 127 | 128 | This is kind of hard to make them communicate easily. 129 | 130 | We will need to create dedicated network for the containers to communicate with each other. 131 | 132 | ```shell 133 | docker network create factorio 134 | ``` 135 | 136 | Then, obtain the IP address of the Factorio server container. 137 | 138 | ```shell 139 | docker container inspect factorio-server --format '{{ .NetworkSettings.Networks.factorio.IPAddress }}' 140 | ``` 141 | 142 | Then, start the API server with the following command with the IP address obtained: 143 | 144 | ```shell 145 | docker run \ 146 | --rm \ 147 | -e FACTORIO_RCON_HOST= \ 148 | -e FACTORIO_RCON_PORT=27015 \ 149 | -e FACTORIO_RCON_PASSWORD=123456 \ 150 | -p 24180:24180 \ 151 | ghcr.io/nekomeowww/factorio-rcon-api:unstable 152 | ``` 153 | 154 | ### Factorio server not ran with Docker, Factorio RCON API ran with Docker 155 | 156 | For running Factorio server and Factorio RCON API in a same server while not having Factorio server in Docker, you can start the API server with the following command: 157 | 158 | ```shell 159 | docker run \ 160 | --rm \ 161 | -e FACTORIO_RCON_HOST=host.docker.internal \ 162 | -e FACTORIO_RCON_PORT=27015 \ 163 | -e FACTORIO_RCON_PASSWORD=123456 \ 164 | -p 24180:24180 \ 165 | ghcr.io/nekomeowww/factorio-rcon-api:unstable 166 | ``` 167 | 168 | ### Call the API 169 | 170 | That's it, you can now call the API with the following command: 171 | 172 | ```shell 173 | curl -X GET http://localhost:24180/api/v2/factorio/console/command/version 174 | ``` 175 | 176 | to get the version of the Factorio game server. 177 | 178 | ## API 179 | 180 | For API documentation, we offer Scalar powered OpenAPI UI under `/apis/docs` endpoint. 181 | 182 | With the demo server at [https://factorio-rcon-api.ayaka.io/apis/docs/v2](https://factorio-rcon-api.ayaka.io/apis/docs/v2) live, you can view the full API documentations there, or you can run the API server locally and access the documentation at [http://localhost:24180/apis/docs/v2](http://localhost:24180/apis/docs/v2). 183 | 184 | Alternatively, we ship the OpenAPI v2 and v3 spec in the repository: 185 | 186 | - OpenAPI v2 spec: [v2.swagger.json](https://github.com/nekomeowww/factorio-rcon-api/blob/main/apis/factorioapi/v2/v2.swagger.json) 187 | - OpenAPI v3 spec: [v2.swagger.v3.yaml](https://github.com/nekomeowww/factorio-rcon-api/blob/main/apis/factorioapi/v2/v2.swagger.v3.yaml) 188 | 189 | ### SDKs 190 | 191 | #### TypeScript / JavaScript 192 | 193 | We are now shipping the SDKs for TypeScript for easier integration with the APIs. 194 | 195 | ##### Installation 196 | 197 | ```shell 198 | ni factorio-rcon-api-client # from @antfu/ni, can be installed via `npm i -g @antfu/ni` 199 | pnpm i factorio-rcon-api-client 200 | yarn i factorio-rcon-api-client 201 | npm i factorio-rcon-api-client 202 | ``` 203 | 204 | ##### Usage 205 | 206 | ```typescript 207 | // Import the client and the API functions 208 | import { client, v2FactorioConsoleCommandRawPost } from 'factorio-rcon-api-client' 209 | 210 | async function main() { 211 | // Set the base URL of the API 212 | client.setConfig({ 213 | baseUrl: 'http://localhost:3000', 214 | }) 215 | 216 | // Call POST /api/v2/factorio/console/command/raw 217 | const res = await v2FactorioConsoleCommandRawPost({ 218 | body: { 219 | input: '/help', // The command to run 220 | }, 221 | }) 222 | 223 | console.log(res) // The response from the API 224 | } 225 | 226 | main().catch(console.error) 227 | ``` 228 | 229 | > [!TIP] 230 | > Additionally, we can ship the SDKs for Lua, and Python (widely used for mods, admin panels, bots) in the future, you are welcome to contribute to the project. 231 | 232 | For developers working with the APIs from Factorio RCON API, you can either use the above OpenAPI specs or use Protobuf files to generate types for TypeScript, Python, Go, and many more languages' SDKs with code generators. We are not going to cover all of these in this README, but you can find more information on the internet: 233 | 234 | - [Stainless | Generate best-in-class SDKs](https://www.stainlessapi.com/) (used by OpenAI, Cloudflare, etc.) 235 | - [Generated SDKs](https://buf.build/docs/bsr/generated-sdks/overview/) 236 | - [Hey API](https://heyapi.dev/) 237 | 238 | ## Star History 239 | 240 | [![Star History Chart](https://api.star-history.com/svg?repos=nekomeowww/factorio-rcon-api&type=Date)](https://star-history.com/#nekomeowww/factorio-rcon-api&Date) 241 | 242 | ## Contributors 243 | 244 | Thanks to all the contributors! 245 | 246 | [![contributors](https://contrib.rocks/image?repo=nekomeowww/factorio-rcon-api)](https://github.com/nekomeowww/factorio-rcon-api/graphs/contributors) 247 | -------------------------------------------------------------------------------- /apis/factorioapi/v1/v1.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.34.0 4 | // protoc (unknown) 5 | // source: apis/factorioapi/v1/v1.proto 6 | 7 | package v1 8 | 9 | import ( 10 | _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" 11 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 12 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 13 | reflect "reflect" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | var File_apis_factorioapi_v1_v1_proto protoreflect.FileDescriptor 24 | 25 | var file_apis_factorioapi_v1_v1_proto_rawDesc = []byte{ 26 | 0x0a, 0x1c, 0x61, 0x70, 0x69, 0x73, 0x2f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x61, 27 | 0x70, 0x69, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 28 | 0x61, 0x70, 0x69, 0x73, 0x2e, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x61, 0x70, 0x69, 29 | 0x2e, 0x76, 0x31, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65, 0x6e, 0x2d, 30 | 0x6f, 0x70, 0x65, 0x6e, 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 31 | 0x73, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 32 | 0x6f, 0x74, 0x6f, 0x42, 0xb0, 0x03, 0x92, 0x41, 0xec, 0x02, 0x12, 0xed, 0x01, 0x0a, 0x11, 0x46, 33 | 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x20, 0x52, 0x43, 0x4f, 0x4e, 0x20, 0x41, 0x50, 0x49, 34 | 0x12, 0xd2, 0x01, 0x41, 0x50, 0x49, 0x20, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x20, 0x6f, 35 | 0x76, 0x65, 0x72, 0x20, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x27, 0x73, 0x20, 0x52, 36 | 0x43, 0x4f, 0x4e, 0x20, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2e, 0x20, 0x2a, 0x2a, 37 | 0x54, 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x76, 0x31, 0x20, 0x76, 38 | 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20, 0x41, 0x50, 39 | 0x49, 0x2c, 0x20, 0x6d, 0x61, 0x64, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x46, 0x61, 0x63, 0x74, 40 | 0x6f, 0x72, 0x69, 0x6f, 0x20, 0x3c, 0x32, 0x2e, 0x78, 0x2e, 0x20, 0x46, 0x6f, 0x72, 0x20, 0x70, 41 | 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x64, 0x65, 0x76, 0x65, 0x6c, 42 | 0x6f, 0x70, 0x65, 0x72, 0x73, 0x20, 0x6f, 0x6e, 0x20, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 43 | 0x6f, 0x20, 0x32, 0x2e, 0x78, 0x20, 0x28, 0x65, 0x73, 0x70, 0x65, 0x63, 0x69, 0x61, 0x6c, 0x6c, 44 | 0x79, 0x20, 0x77, 0x69, 0x74, 0x68, 0x20, 0x53, 0x70, 0x61, 0x63, 0x65, 0x20, 0x41, 0x67, 0x65, 45 | 0x20, 0x44, 0x4c, 0x43, 0x29, 0x2c, 0x20, 0x79, 0x6f, 0x75, 0x20, 0x73, 0x68, 0x6f, 0x75, 0x6c, 46 | 0x64, 0x20, 0x75, 0x73, 0x65, 0x20, 0x76, 0x32, 0x20, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 47 | 0x74, 0x73, 0x2e, 0x2a, 0x2a, 0x32, 0x03, 0x31, 0x2e, 0x30, 0x52, 0x3d, 0x0a, 0x03, 0x35, 0x30, 48 | 0x30, 0x12, 0x36, 0x0a, 0x15, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x20, 0x53, 0x65, 49 | 0x72, 0x76, 0x65, 0x72, 0x20, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x1b, 0x1a, 0x19, 50 | 0x2e, 0x61, 0x70, 0x69, 0x73, 0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x72, 51 | 0x72, 0x6f, 0x72, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x3b, 0x0a, 0x03, 0x35, 0x30, 0x33, 52 | 0x12, 0x34, 0x0a, 0x13, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x20, 0x55, 0x6e, 0x61, 0x76, 53 | 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x1b, 0x1a, 0x19, 0x2e, 0x61, 0x70, 54 | 0x69, 0x73, 0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 55 | 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 56 | 0x6f, 0x6d, 0x2f, 0x6e, 0x65, 0x6b, 0x6f, 0x6d, 0x65, 0x6f, 0x77, 0x77, 0x77, 0x2f, 0x66, 0x61, 57 | 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x2d, 0x72, 0x63, 0x6f, 0x6e, 0x2d, 0x61, 0x70, 0x69, 0x2f, 58 | 0x76, 0x32, 0x2f, 0x61, 0x70, 0x69, 0x73, 0x2f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 59 | 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 60 | } 61 | 62 | var file_apis_factorioapi_v1_v1_proto_goTypes = []interface{}{} 63 | var file_apis_factorioapi_v1_v1_proto_depIdxs = []int32{ 64 | 0, // [0:0] is the sub-list for method output_type 65 | 0, // [0:0] is the sub-list for method input_type 66 | 0, // [0:0] is the sub-list for extension type_name 67 | 0, // [0:0] is the sub-list for extension extendee 68 | 0, // [0:0] is the sub-list for field type_name 69 | } 70 | 71 | func init() { file_apis_factorioapi_v1_v1_proto_init() } 72 | func file_apis_factorioapi_v1_v1_proto_init() { 73 | if File_apis_factorioapi_v1_v1_proto != nil { 74 | return 75 | } 76 | type x struct{} 77 | out := protoimpl.TypeBuilder{ 78 | File: protoimpl.DescBuilder{ 79 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 80 | RawDescriptor: file_apis_factorioapi_v1_v1_proto_rawDesc, 81 | NumEnums: 0, 82 | NumMessages: 0, 83 | NumExtensions: 0, 84 | NumServices: 0, 85 | }, 86 | GoTypes: file_apis_factorioapi_v1_v1_proto_goTypes, 87 | DependencyIndexes: file_apis_factorioapi_v1_v1_proto_depIdxs, 88 | }.Build() 89 | File_apis_factorioapi_v1_v1_proto = out.File 90 | file_apis_factorioapi_v1_v1_proto_rawDesc = nil 91 | file_apis_factorioapi_v1_v1_proto_goTypes = nil 92 | file_apis_factorioapi_v1_v1_proto_depIdxs = nil 93 | } 94 | -------------------------------------------------------------------------------- /apis/factorioapi/v1/v1.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package apis.factorioapi.v1; 4 | 5 | import "protoc-gen-openapiv2/options/annotations.proto"; 6 | 7 | option go_package = "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v1"; 8 | option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { 9 | info: { 10 | title: "Factorio RCON API"; 11 | description: "API wrapper over Factorio's RCON protocol. **This is the v1 version of the API, made for Factorio <2.x. For players and developers on Factorio 2.x (especially with Space Age DLC), you should use v2 endpoints.**"; 12 | version: "1.0"; 13 | }; 14 | responses: { 15 | key: "500"; 16 | value: { 17 | description: "Internal Server Error"; 18 | schema: { 19 | json_schema: {ref: ".apis.jsonapi.ErrorObject"} 20 | } 21 | } 22 | } 23 | responses: { 24 | key: "503"; 25 | value: { 26 | description: "Service Unavailable"; 27 | schema: { 28 | json_schema: {ref: ".apis.jsonapi.ErrorObject"} 29 | } 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /apis/factorioapi/v1/v1_swagger.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "embed" 5 | 6 | "github.com/samber/lo" 7 | ) 8 | 9 | //go:embed v1.swagger.v3.json 10 | var openAPIV3SpecJSON embed.FS 11 | 12 | //go:embed v1.swagger.v3.yaml 13 | var openAPIV3SpecYaml embed.FS 14 | 15 | func OpenAPIV3SpecJSON() []byte { 16 | return lo.Must(openAPIV3SpecJSON.ReadFile("v1.swagger.v3.json")) 17 | } 18 | 19 | func OpenAPIV3SpecYaml() []byte { 20 | return lo.Must(openAPIV3SpecYaml.ReadFile("v1.swagger.v3.yaml")) 21 | } 22 | -------------------------------------------------------------------------------- /apis/factorioapi/v2/v2.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.34.0 4 | // protoc (unknown) 5 | // source: apis/factorioapi/v2/v2.proto 6 | 7 | package v2 8 | 9 | import ( 10 | _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options" 11 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 12 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 13 | reflect "reflect" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | var File_apis_factorioapi_v2_v2_proto protoreflect.FileDescriptor 24 | 25 | var file_apis_factorioapi_v2_v2_proto_rawDesc = []byte{ 26 | 0x0a, 0x1c, 0x61, 0x70, 0x69, 0x73, 0x2f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x61, 27 | 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x76, 0x32, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 28 | 0x61, 0x70, 0x69, 0x73, 0x2e, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x61, 0x70, 0x69, 29 | 0x2e, 0x76, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x2d, 0x67, 0x65, 0x6e, 0x2d, 30 | 0x6f, 0x70, 0x65, 0x6e, 0x61, 0x70, 0x69, 0x76, 0x32, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 31 | 0x73, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 32 | 0x6f, 0x74, 0x6f, 0x42, 0xc7, 0x02, 0x92, 0x41, 0x83, 0x02, 0x12, 0x84, 0x01, 0x0a, 0x11, 0x46, 33 | 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x20, 0x52, 0x43, 0x4f, 0x4e, 0x20, 0x41, 0x50, 0x49, 34 | 0x12, 0x6a, 0x41, 0x50, 0x49, 0x20, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x20, 0x6f, 0x76, 35 | 0x65, 0x72, 0x20, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x27, 0x73, 0x20, 0x52, 0x43, 36 | 0x4f, 0x4e, 0x20, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x2e, 0x20, 0x2a, 0x2a, 0x54, 37 | 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x76, 0x32, 0x20, 0x76, 0x65, 38 | 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20, 0x41, 0x50, 0x49, 39 | 0x2c, 0x20, 0x6d, 0x61, 0x64, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x46, 0x61, 0x63, 0x74, 0x6f, 40 | 0x72, 0x69, 0x6f, 0x20, 0x3e, 0x3d, 0x32, 0x2e, 0x78, 0x2e, 0x2a, 0x2a, 0x32, 0x03, 0x32, 0x2e, 41 | 0x30, 0x52, 0x3d, 0x0a, 0x03, 0x35, 0x30, 0x30, 0x12, 0x36, 0x0a, 0x15, 0x49, 0x6e, 0x74, 0x65, 42 | 0x72, 0x6e, 0x61, 0x6c, 0x20, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x20, 0x45, 0x72, 0x72, 0x6f, 43 | 0x72, 0x12, 0x1d, 0x0a, 0x1b, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x2e, 0x6a, 0x73, 0x6f, 44 | 0x6e, 0x61, 0x70, 0x69, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 45 | 0x52, 0x3b, 0x0a, 0x03, 0x35, 0x30, 0x33, 0x12, 0x34, 0x0a, 0x13, 0x53, 0x65, 0x72, 0x76, 0x69, 46 | 0x63, 0x65, 0x20, 0x55, 0x6e, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x1d, 47 | 0x0a, 0x1b, 0x1a, 0x19, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x61, 0x70, 48 | 0x69, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5a, 0x3e, 0x67, 49 | 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6e, 0x65, 0x6b, 0x6f, 0x6d, 0x65, 50 | 0x6f, 0x77, 0x77, 0x77, 0x2f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x2d, 0x72, 0x63, 51 | 0x6f, 0x6e, 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x70, 0x69, 0x73, 0x2f, 0x66, 52 | 0x61, 0x63, 0x74, 0x6f, 0x72, 0x69, 0x6f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x32, 0x62, 0x06, 0x70, 53 | 0x72, 0x6f, 0x74, 0x6f, 0x33, 54 | } 55 | 56 | var file_apis_factorioapi_v2_v2_proto_goTypes = []interface{}{} 57 | var file_apis_factorioapi_v2_v2_proto_depIdxs = []int32{ 58 | 0, // [0:0] is the sub-list for method output_type 59 | 0, // [0:0] is the sub-list for method input_type 60 | 0, // [0:0] is the sub-list for extension type_name 61 | 0, // [0:0] is the sub-list for extension extendee 62 | 0, // [0:0] is the sub-list for field type_name 63 | } 64 | 65 | func init() { file_apis_factorioapi_v2_v2_proto_init() } 66 | func file_apis_factorioapi_v2_v2_proto_init() { 67 | if File_apis_factorioapi_v2_v2_proto != nil { 68 | return 69 | } 70 | type x struct{} 71 | out := protoimpl.TypeBuilder{ 72 | File: protoimpl.DescBuilder{ 73 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 74 | RawDescriptor: file_apis_factorioapi_v2_v2_proto_rawDesc, 75 | NumEnums: 0, 76 | NumMessages: 0, 77 | NumExtensions: 0, 78 | NumServices: 0, 79 | }, 80 | GoTypes: file_apis_factorioapi_v2_v2_proto_goTypes, 81 | DependencyIndexes: file_apis_factorioapi_v2_v2_proto_depIdxs, 82 | }.Build() 83 | File_apis_factorioapi_v2_v2_proto = out.File 84 | file_apis_factorioapi_v2_v2_proto_rawDesc = nil 85 | file_apis_factorioapi_v2_v2_proto_goTypes = nil 86 | file_apis_factorioapi_v2_v2_proto_depIdxs = nil 87 | } 88 | -------------------------------------------------------------------------------- /apis/factorioapi/v2/v2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package apis.factorioapi.v2; 4 | 5 | import "protoc-gen-openapiv2/options/annotations.proto"; 6 | 7 | option go_package = "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v2"; 8 | option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { 9 | info: { 10 | title: "Factorio RCON API"; 11 | description: "API wrapper over Factorio's RCON protocol. **This is the v2 version of the API, made for Factorio >=2.x.**"; 12 | version: "2.0"; 13 | }; 14 | responses: { 15 | key: "500"; 16 | value: { 17 | description: "Internal Server Error"; 18 | schema: { 19 | json_schema: {ref: ".apis.jsonapi.ErrorObject"} 20 | } 21 | } 22 | } 23 | responses: { 24 | key: "503"; 25 | value: { 26 | description: "Service Unavailable"; 27 | schema: { 28 | json_schema: {ref: ".apis.jsonapi.ErrorObject"} 29 | } 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /apis/jsonapi/jsonapi.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package apis.jsonapi; 4 | 5 | import "google/protobuf/any.proto"; 6 | import "protoc-gen-openapiv2/options/annotations.proto"; 7 | 8 | option go_package = "github.com/nekomeowww/factorio-rcon-api/v2/apis/jsonapi"; 9 | 10 | // Where specified, a links member can be used to represent links. 11 | message Links { 12 | // a string whose value is a URI-reference [RFC3986 Section 4.1] pointing to the link’s target. 13 | string href = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"https://apidocs.example.com/errors/BAD_REQUEST\""}]; 14 | // a string indicating the link’s relation type. 15 | optional string rel = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"external\""}]; 16 | // a link to a description document (e.g. OpenAPI or JSON Schema) for the link target. 17 | optional string describedby = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"OpenAPI\""}]; 18 | // a string which serves as a label for the destination of a link 19 | // such that it can be used as a human-readable identifier (e.g., a menu entry). 20 | optional string title = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"Learn more about BAD_REQUEST\""}]; 21 | // a string indicating the media type of the link’s target. 22 | optional string type = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"text/html\""}]; 23 | // a string or an array of strings indicating the language(s) of the link’s target. 24 | // An array of strings indicates that the link’s target is available in multiple languages. 25 | optional string hreflang = 6 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"en-US\""}]; 26 | // a meta object containing non-standard meta-information about the link. 27 | map meta = 7; 28 | } 29 | 30 | message Response { 31 | // Data is the primary data for a response. 32 | repeated google.protobuf.Any data = 1; 33 | // Errors is an array of error objects. 34 | repeated ErrorObject errors = 2; 35 | } 36 | 37 | message ErrorObjectSource { 38 | // a JSON Pointer [RFC6901] to the value in the request document that caused the error 39 | // [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. 40 | string pointer = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"/user/age\""}]; 41 | // a string indicating which URI query parameter caused the error. 42 | string parameter = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"created_at.ASC\""}]; 43 | // a string indicating the name of a single request header which caused the error. 44 | string header = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"X-SOME-HEADER\""}]; 45 | } 46 | 47 | message ErrorObject { 48 | // a unique identifier for this particular occurrence of the problem. 49 | string id = 1 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"BAD_REQUEST\""}]; 50 | // a links object containing references to the source of the error. 51 | optional Links links = 2; 52 | // the HTTP status code applicable to this problem, expressed as a string value. 53 | uint32 status = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"400\""}]; 54 | // an application-specific error code, expressed as a string value. 55 | string code = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"USER_UPDATE_FAILED\""}]; 56 | // a short, human-readable summary of the problem 57 | string title = 5 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"Failed to update user's profile, invalid parameter(s) detected\""}]; 58 | // a human-readable explanation specific to this occurrence of the problem. Like title. 59 | string detail = 6 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"A field under /user/age is not correct, should be 'number' instead of 'string'\""}]; 60 | // an object containing references to the source of the error. 61 | optional ErrorObjectSource source = 7; 62 | // a meta object containing non-standard meta-information about the error. 63 | map meta = 8; 64 | } 65 | 66 | message ErrorCaller { 67 | string file = 1; 68 | int32 line = 2; 69 | string function = 3; 70 | } 71 | 72 | // pageInfo is used to indicate whether more edges exist prior or following the set defined by the clients arguments. 73 | message PageInfo { 74 | // hasPreviousPage is used to indicate whether more edges exist prior to the set defined by the clients arguments. 75 | // If the client is paginating with last/before, then the server must return true if prior edges exist, otherwise false. 76 | // If the client is paginating with first/after, then the client may return true if edges prior to after exist, 77 | // if it can do so efficiently, otherwise may return false. 78 | bool has_previous_page = 1; 79 | // hasNextPage is used to indicate whether more edges exist following the set defined by the clients arguments. 80 | // If the client is paginating with first/after, then the server must return true if further edges exist, otherwise false. 81 | // If the client is paginating with last/before, then the client may return true if edges further from before exist, 82 | // if it can do so efficiently, otherwise may return false. 83 | bool has_next_page = 2; 84 | // startCursor is the cursor to the first node in edges. Or the cursor of the representation of the first returned element. 85 | optional string start_cursor = 3 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"aGVsbG8=\""}]; 86 | // endCursor is the cursor to the last node in edges. Or the cursor of the representation of the last returned element. 87 | optional string end_cursor = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"aGVsbG8=\""}]; 88 | } 89 | 90 | message PaginationRequest { 91 | // first is the number of items to return from the beginning of the list. 92 | int64 first = 1; 93 | // after is the cursor to the first node in edges that should be returned. 94 | optional string after = 2 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"aGVsbG8=\""}]; 95 | // last is the number of items to return from the end of the list. 96 | int64 last = 3; 97 | // before is the cursor to the last node in edges that should be returned. 98 | optional string before = 4 [(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {example: "\"aGVsbG8=\""}]; 99 | } 100 | -------------------------------------------------------------------------------- /apis/sdk/typescript/README.md: -------------------------------------------------------------------------------- 1 | # `factorio-rcon-api-client` 2 | 3 | Generated TypeScript SDK client for [https://github.com/nekomeowww/factorio-rcon-api](https://github.com/nekomeowww/factorio-rcon-api) 4 | 5 | ## Getting started 6 | 7 | ```shell 8 | ni factorio-rcon-api-client # from @antfu/ni, can be installed via `npm i -g @antfu/ni` 9 | pnpm i factorio-rcon-api-client 10 | yarn i factorio-rcon-api-client 11 | npm i factorio-rcon-api-client 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```ts 17 | // Import the client and the API functions 18 | import { client, v2FactorioConsoleCommandRawPost } from 'factorio-rcon-api-client' 19 | 20 | async function main() { 21 | // Set the base URL of the API 22 | client.setConfig({ 23 | baseUrl: 'http://localhost:3000', 24 | }) 25 | 26 | // Call POST /api/v2/factorio/console/command/raw 27 | const res = await v2FactorioConsoleCommandRawPost({ 28 | body: { 29 | input: '/help', // The command to run 30 | }, 31 | }) 32 | 33 | console.log(res) // The response from the API 34 | } 35 | 36 | main().catch(console.error) 37 | ``` 38 | -------------------------------------------------------------------------------- /apis/sdk/typescript/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu( 5 | { 6 | formatters: true, 7 | yaml: false, 8 | markdown: false, 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /apis/sdk/typescript/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@hey-api/openapi-ts' 2 | 3 | export default defineConfig({ 4 | client: '@hey-api/client-fetch', 5 | input: '../../factorioapi/v1/v1.swagger.v3.json', 6 | output: 'src/client', 7 | }) 8 | -------------------------------------------------------------------------------- /apis/sdk/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "factorio-rcon-api-client", 3 | "type": "module", 4 | "version": "2.0.4", 5 | "private": false, 6 | "description": "Generated TypeScript SDK client for https://github.com/nekomeowww/factorio-rcon-api", 7 | "author": { 8 | "name": "Neko Ayaka", 9 | "email": "neko@ayaka.moe", 10 | "url": "https://github.com/nekomeowww" 11 | }, 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/nekomeowww/factorio-rcon-api.git", 16 | "directory": "apis/sdk/typescript" 17 | }, 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "import": "./dist/index.mjs", 22 | "require": "./dist/index.cjs" 23 | } 24 | }, 25 | "main": "./dist/index.cjs", 26 | "module": "./dist/index.mjs", 27 | "types": "./dist/index.d.ts", 28 | "files": [ 29 | "README.md", 30 | "dist", 31 | "package.json" 32 | ], 33 | "scripts": { 34 | "openapi-ts": "openapi-ts", 35 | "build": "unbuild", 36 | "package:publish": "pnpm build && pnpm publish --access public --no-git-checks" 37 | }, 38 | "dependencies": { 39 | "@hey-api/client-fetch": "^0.11.0" 40 | }, 41 | "devDependencies": { 42 | "@antfu/eslint-config": "^4.11.0", 43 | "@hey-api/openapi-ts": "^0.69.0", 44 | "eslint": "^9.23.0", 45 | "eslint-plugin-format": "^1.0.1", 46 | "unbuild": "^3.5.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apis/sdk/typescript/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - esbuild 3 | -------------------------------------------------------------------------------- /apis/sdk/typescript/src/client/index.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | export * from './types.gen'; 3 | export * from './sdk.gen'; -------------------------------------------------------------------------------- /apis/sdk/typescript/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client' 2 | -------------------------------------------------------------------------------- /apis/sdk/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext" 6 | ], 7 | "module": "ESNext", 8 | "moduleResolution": "bundler", 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "isolatedModules": true, 12 | "verbatimModuleSyntax": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": [ 16 | "src/**/*.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | plugins: 3 | - remote: buf.build/grpc/go:v1.3.0 4 | out: . 5 | opt: paths=source_relative 6 | 7 | - remote: buf.build/protocolbuffers/go:v1.34.0 8 | out: . 9 | opt: paths=source_relative 10 | 11 | - remote: buf.build/grpc-ecosystem/gateway:v2.19.1 12 | out: . 13 | opt: 14 | - paths=source_relative 15 | - allow_delete_body=true 16 | 17 | - remote: buf.build/grpc-ecosystem/openapiv2:v2.19.1 18 | out: ./apis/factorioapi/v1 19 | opt: 20 | - file=./apis/factorioapi/v1 21 | - merge_file_name=v1 22 | - allow_merge=true 23 | - allow_delete_body=true 24 | - disable_default_errors=true 25 | - disable_service_tags=true 26 | # - output_format=yaml 27 | 28 | - remote: buf.build/grpc-ecosystem/openapiv2:v2.19.1 29 | out: ./apis/factorioapi/v2 30 | opt: 31 | - file=./apis/factorioapi/v2 32 | - merge_file_name=v2 33 | - allow_merge=true 34 | - allow_delete_body=true 35 | - disable_default_errors=true 36 | - disable_service_tags=true 37 | # - output_format=yaml 38 | 39 | # - name: grpc-gateway-ts 40 | # out: ./clients/typescript 41 | -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v2 3 | deps: 4 | - name: buf.build/googleapis/googleapis 5 | commit: e93e34f48be043dab55be31b4b47f458 6 | digest: b5:cebe5dfac5f7d67c55296f37ad9d368dba8d9862777e69d5d99eb1d72dc95fa68cd6323b483ca42cf70e66060002c1bc36e1f5f754b217a5c771c108eb243dbf 7 | - name: buf.build/grpc-ecosystem/grpc-gateway 8 | commit: 4c5ba75caaf84e928b7137ae5c18c26a 9 | digest: b5:c113e62fb3b29289af785866cae062b55ec8ae19ab3f08f3004098928fbca657730a06810b2012951294326b95669547194fa84476b9e9b688d4f8bf77a0691d 10 | - name: buf.build/protocolbuffers/wellknowntypes 11 | commit: d4f14e5e0a9c40889c90d373c74e95eb 12 | digest: b5:39b4d0887abcd8ee1594086283f4120f688e1c33ec9ccd554ab0362ad9ad482154d0e07e3787d394bb22970930b452aac1c5c105c05efe129cec299ff5b5e05e 13 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | breaking: 3 | use: 4 | - FILE 5 | lint: 6 | use: 7 | - DEFAULT 8 | except: 9 | - PACKAGE_VERSION_SUFFIX 10 | - SERVICE_SUFFIX 11 | - ENUM_VALUE_UPPER_SNAKE_CASE 12 | - ENUM_ZERO_VALUE_SUFFIX 13 | - ENUM_VALUE_PREFIX 14 | deps: 15 | - buf.build/googleapis/googleapis 16 | - buf.build/protocolbuffers/wellknowntypes 17 | - buf.build/grpc-ecosystem/grpc-gateway 18 | -------------------------------------------------------------------------------- /cmd/api-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "go.uber.org/fx" 9 | 10 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/configs" 11 | grpcservers "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers" 12 | apiserver "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers/factorioapi/apiserver" 13 | grpcservices "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/services" 14 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/libs" 15 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var ( 20 | configFilePath string 21 | envFilePath string 22 | ) 23 | 24 | func main() { 25 | root := &cobra.Command{ 26 | Use: "api-server", 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | app := fx.New( 29 | fx.Provide(configs.NewConfig("factorio-rcon-api", "api-server", configFilePath, envFilePath)), 30 | fx.Options(libs.Modules()), 31 | fx.Options(rcon.Modules()), 32 | fx.Options(grpcservers.Modules()), 33 | fx.Options(grpcservices.Modules()), 34 | fx.Invoke(apiserver.RunGRPCServer()), 35 | fx.Invoke(apiserver.RunGatewayServer()), 36 | ) 37 | 38 | app.Run() 39 | 40 | stopCtx, stopCtxCancel := context.WithTimeout(context.Background(), time.Minute*5) 41 | defer stopCtxCancel() 42 | 43 | if err := app.Stop(stopCtx); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | }, 49 | } 50 | 51 | root.Flags().StringVarP(&configFilePath, "config", "c", "", "config file path") 52 | root.Flags().StringVarP(&envFilePath, "env", "e", "", "env file path") 53 | 54 | if err := root.Execute(); err != nil { 55 | log.Fatal(err) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/tools/openapiv2conv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/getkin/kin-openapi/openapi2" 11 | "github.com/getkin/kin-openapi/openapi2conv" 12 | "github.com/nekomeowww/xo" 13 | "github.com/spf13/cobra" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | var ( 18 | input string 19 | inputFormat string 20 | output string 21 | outputFormat string 22 | ) 23 | 24 | func main() { 25 | root := &cobra.Command{ 26 | Use: "openapiv2conv", 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | if !strings.HasPrefix(input, "/") { 29 | input = xo.RelativePathBasedOnPwdOf(input) 30 | } 31 | 32 | openapiV2DocContent, err := os.ReadFile(input) 33 | if err != nil { 34 | return fmt.Errorf("failed to read input file %s: %w", input, err) 35 | } 36 | 37 | var openapiV2Doc openapi2.T 38 | 39 | switch inputFormat { 40 | case "json": 41 | err = json.Unmarshal(openapiV2DocContent, &openapiV2Doc) 42 | if err != nil { 43 | return fmt.Errorf("failed to unmarshal input file %s: %w", input, err) 44 | } 45 | case "yaml": 46 | err = yaml.Unmarshal(openapiV2DocContent, &openapiV2Doc) 47 | if err != nil { 48 | return fmt.Errorf("failed to unmarshal input file %s: %w", input, err) 49 | } 50 | default: 51 | return fmt.Errorf("unsupported input format: %s", inputFormat) 52 | } 53 | 54 | openapiV3Doc, err := openapi2conv.ToV3(&openapiV2Doc) 55 | if err != nil { 56 | return fmt.Errorf("failed to convert openapi v2 to v3: %w", err) 57 | } 58 | 59 | openapiV3DocBuffer := new(bytes.Buffer) 60 | 61 | switch outputFormat { 62 | case "json": 63 | encoder := json.NewEncoder(openapiV3DocBuffer) 64 | encoder.SetIndent("", " ") 65 | 66 | err = encoder.Encode(openapiV3Doc) 67 | if err != nil { 68 | return fmt.Errorf("failed to encode openapi v3 doc: %w", err) 69 | } 70 | case "yaml": 71 | encoder := yaml.NewEncoder(openapiV3DocBuffer) 72 | encoder.SetIndent(2) 73 | 74 | err = encoder.Encode(openapiV3Doc) 75 | if err != nil { 76 | return fmt.Errorf("failed to encode openapi v3 doc: %w", err) 77 | } 78 | default: 79 | return fmt.Errorf("unsupported output format: %s", outputFormat) 80 | } 81 | 82 | if !strings.HasPrefix(output, "/") { 83 | output = xo.RelativePathBasedOnPwdOf(output) 84 | } 85 | 86 | err = os.WriteFile(output, openapiV3DocBuffer.Bytes(), 0644) //nolint 87 | if err != nil { 88 | return fmt.Errorf("failed to write output file %s: %w", output, err) 89 | } 90 | 91 | return nil 92 | }, 93 | } 94 | 95 | root.Flags().StringVarP(&input, "input", "i", "", "input file path") 96 | root.Flags().StringVar(&inputFormat, "input-format", "json", "input file format") 97 | root.Flags().StringVarP(&output, "output", "o", "", "output file path") 98 | root.Flags().StringVar(&outputFormat, "output-format", "yaml", "output file format") 99 | 100 | err := root.Execute() 101 | if err != nil { 102 | panic(err) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /cspell.config.yaml: -------------------------------------------------------------------------------- 1 | version: "0.2" 2 | ignorePaths: [] 3 | dictionaryDefinitions: [] 4 | dictionaries: [] 5 | words: 6 | - apierrors 7 | - apiserver 8 | - Ayaka 9 | - cenkalti 10 | - Factorio 11 | - factorioapi 12 | - fatcontext 13 | - gorcon 14 | - grpcotel 15 | - grpcpkg 16 | - grpcservers 17 | - grpcservices 18 | - healthz 19 | - httppkg 20 | - labstack 21 | - maxbrunsfeld 22 | - Neko 23 | - nekomeowww 24 | - nolint 25 | - nonamedreturns 26 | - opentelemetry 27 | - Otel 28 | - otelgrpc 29 | - otelhttp 30 | - protovalidate 31 | - RCON 32 | - samber 33 | - Unmute 34 | ignoreWords: [] 35 | import: [] 36 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | factorio: 5 | image: factoriotools/factorio 6 | restart: unless-stopped 7 | ports: 8 | - "34197:34197/udp" # Game port 9 | - "27015:27015/tcp" # RCON port 10 | volumes: 11 | - ./.factorio:/factorio 12 | -------------------------------------------------------------------------------- /docs/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekomeowww/factorio-rcon-api/55b61b7829f713030f8cd9af331324c2bb2f98cd/docs/demo.mp4 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nekomeowww/factorio-rcon-api/v2 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | buf.build/go/protovalidate v0.12.0 7 | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 8 | github.com/alexliesenfeld/health v0.8.0 9 | github.com/cenkalti/backoff/v4 v4.3.0 10 | github.com/cenkalti/backoff/v5 v5.0.2 11 | github.com/getkin/kin-openapi v0.132.0 12 | github.com/go-viper/mapstructure/v2 v2.2.1 13 | github.com/golang-module/carbon v1.7.3 14 | github.com/gorcon/rcon v1.4.0 15 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 16 | github.com/joho/godotenv v1.5.1 17 | github.com/labstack/echo/v4 v4.13.4 18 | github.com/maxbrunsfeld/counterfeiter/v6 v6.11.2 19 | github.com/nekomeowww/fo v1.6.0 20 | github.com/nekomeowww/xo v1.16.0 21 | github.com/samber/lo v1.50.0 22 | github.com/spf13/cobra v1.9.1 23 | github.com/spf13/viper v1.20.1 24 | github.com/stretchr/testify v1.10.0 25 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 26 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 27 | go.opentelemetry.io/contrib/instrumentation/runtime v0.61.0 28 | go.opentelemetry.io/otel v1.36.0 29 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.36.0 30 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 31 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 32 | go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 33 | go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 34 | go.opentelemetry.io/otel/sdk v1.36.0 35 | go.opentelemetry.io/otel/sdk/metric v1.36.0 36 | go.uber.org/fx v1.24.0 37 | go.uber.org/zap v1.27.0 38 | google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a 39 | google.golang.org/grpc v1.72.2 40 | google.golang.org/protobuf v1.36.6 41 | gopkg.in/yaml.v3 v3.0.1 42 | ) 43 | 44 | require ( 45 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect 46 | cel.dev/expr v0.23.1 // indirect 47 | entgo.io/ent v0.14.4 // indirect 48 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 49 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 50 | github.com/felixge/httpsnoop v1.0.4 // indirect 51 | github.com/fsnotify/fsnotify v1.8.0 // indirect 52 | github.com/go-logr/logr v1.4.2 // indirect 53 | github.com/go-logr/stdr v1.2.2 // indirect 54 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 55 | github.com/go-openapi/swag v0.23.1 // indirect 56 | github.com/gobuffalo/envy v1.10.2 // indirect 57 | github.com/gobuffalo/packd v1.0.2 // indirect 58 | github.com/gobuffalo/packr v1.30.1 // indirect 59 | github.com/google/cel-go v0.25.0 // indirect 60 | github.com/google/uuid v1.6.0 // indirect 61 | github.com/gookit/color v1.5.4 // indirect 62 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 63 | github.com/josharian/intern v1.0.0 // indirect 64 | github.com/labstack/gommon v0.4.2 // indirect 65 | github.com/mailru/easyjson v0.9.0 // indirect 66 | github.com/mattn/go-colorable v0.1.14 // indirect 67 | github.com/mattn/go-isatty v0.0.20 // indirect 68 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 69 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 70 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 71 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 72 | github.com/perimeterx/marshmallow v1.1.5 // indirect 73 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 74 | github.com/rogpeppe/go-internal v1.14.1 // indirect 75 | github.com/sagikazarmark/locafero v0.9.0 // indirect 76 | github.com/shopspring/decimal v1.4.0 // indirect 77 | github.com/sirupsen/logrus v1.9.3 // indirect 78 | github.com/sourcegraph/conc v0.3.0 // indirect 79 | github.com/spf13/afero v1.14.0 // indirect 80 | github.com/spf13/cast v1.7.1 // indirect 81 | github.com/spf13/pflag v1.0.6 // indirect 82 | github.com/stoewer/go-strcase v1.3.0 // indirect 83 | github.com/subosito/gotenv v1.6.0 // indirect 84 | github.com/valyala/bytebufferpool v1.0.0 // indirect 85 | github.com/valyala/fasttemplate v1.2.2 // indirect 86 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 87 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 88 | go.opentelemetry.io/otel/log v0.11.0 // indirect 89 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 90 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 91 | go.opentelemetry.io/proto/otlp v1.6.0 // indirect 92 | go.uber.org/dig v1.19.0 // indirect 93 | go.uber.org/multierr v1.11.0 // indirect 94 | golang.org/x/crypto v0.38.0 // indirect 95 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 96 | golang.org/x/mod v0.24.0 // indirect 97 | golang.org/x/net v0.40.0 // indirect 98 | golang.org/x/sync v0.14.0 // indirect 99 | golang.org/x/sys v0.33.0 // indirect 100 | golang.org/x/text v0.25.0 // indirect 101 | golang.org/x/tools v0.31.0 // indirect 102 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /hack/proto-export: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_PATH=$(realpath "$0") 4 | SCRIPT_DIR=$(dirname "$SCRIPT_PATH") 5 | PROTO_VENDOR_DIR="$SCRIPT_DIR/../.temp/cache/buf.build/vendor/proto" 6 | 7 | buf dep update 8 | yq '.deps' buf.yaml | sed 's|^-*||' | xargs -I {} buf export {} --output $PROTO_VENDOR_DIR 9 | -------------------------------------------------------------------------------- /hack/proto-gen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_PATH=$(realpath "$0") 4 | SCRIPT_DIR=$(dirname "$SCRIPT_PATH") 5 | PROTO_APIS_DIR="$SCRIPT_DIR/../apis" 6 | 7 | buf generate --path "$PROTO_APIS_DIR" 8 | 9 | 10 | go build \ 11 | -a \ 12 | -o "release/tools/openapiv2conv" \ 13 | "./cmd/tools/openapiv2conv" 14 | 15 | chmod +x release/tools/openapiv2conv 16 | 17 | ./release/tools/openapiv2conv -i apis/factorioapi/v1/v1.swagger.json --input-format json -o apis/factorioapi/v1/v1.swagger.v3.yaml --output-format yaml 18 | ./release/tools/openapiv2conv -i apis/factorioapi/v1/v1.swagger.json --input-format json -o apis/factorioapi/v1/v1.swagger.v3.json --output-format json 19 | 20 | ./release/tools/openapiv2conv -i apis/factorioapi/v2/v2.swagger.json --input-format json -o apis/factorioapi/v2/v2.swagger.v3.yaml --output-format yaml 21 | ./release/tools/openapiv2conv -i apis/factorioapi/v2/v2.swagger.json --input-format json -o apis/factorioapi/v2/v2.swagger.v3.json --output-format json 22 | -------------------------------------------------------------------------------- /internal/configs/common.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/joho/godotenv" 11 | "github.com/nekomeowww/xo" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | const ConfigFilePathEnvName = "CONFIG_FILE_PATH" 16 | 17 | func getConfigFilePath(configFilePath string) string { 18 | if configFilePath != "" { 19 | return configFilePath 20 | } 21 | 22 | envPath := os.Getenv(ConfigFilePathEnvName) 23 | if envPath != "" { 24 | return envPath 25 | } 26 | 27 | configPath := xo.RelativePathBasedOnPwdOf("config/config.yaml") 28 | 29 | return configPath 30 | } 31 | 32 | var ( 33 | possibleConfigPathsForUnitTest = []string{ 34 | "config.local.yml", 35 | "config.local.yaml", 36 | "config.test.yml", 37 | "config.test.yaml", 38 | "config.example.yml", 39 | "config.example.yaml", 40 | } 41 | ) 42 | 43 | func tryToMatchConfigPathForUnitTest(configFilePath string) string { 44 | if getConfigFilePath(configFilePath) != "" { 45 | return configFilePath 46 | } 47 | 48 | for _, path := range possibleConfigPathsForUnitTest { 49 | stat, err := os.Stat(filepath.Join(xo.RelativePathOf("../../config"), path)) 50 | if err == nil { 51 | if stat.IsDir() { 52 | panic("config file path is a directory: " + path) 53 | } 54 | 55 | return path 56 | } 57 | if errors.Is(err, os.ErrNotExist) { 58 | continue 59 | } else { 60 | panic(err) 61 | } 62 | } 63 | 64 | return "" 65 | } 66 | 67 | func loadEnvConfig(path string) error { 68 | err := godotenv.Load(path) 69 | if err != nil { 70 | if errors.Is(err, os.ErrNotExist) { 71 | err := godotenv.Load(xo.RelativePathBasedOnPwdOf("./.env")) 72 | if err != nil && !errors.Is(err, os.ErrNotExist) { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func readConfig(path string) error { 86 | viper.SetConfigName("app") 87 | viper.SetConfigType("yaml") 88 | viper.SetConfigFile(path) 89 | 90 | viper.AutomaticEnv() 91 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 92 | 93 | err := viper.ReadInConfig() 94 | if err != nil { 95 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { //nolint:errorlint 96 | return nil 97 | } 98 | if os.IsNotExist(err) { 99 | return nil 100 | } 101 | 102 | return fmt.Errorf("error occurred when read in config, error is: %s, err: %w", fmt.Sprintf("%T", err), err) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/configs/configs.go: -------------------------------------------------------------------------------- 1 | package configs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/go-viper/mapstructure/v2" 8 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/meta" 9 | "github.com/nekomeowww/xo" 10 | "github.com/samber/lo" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | type APIServer struct { 15 | GrpcServerAddr string `json:"grpc_server_addr" yaml:"grpc_server_addr"` 16 | HTTPServerAddr string `json:"http_server_addr" yaml:"http_server_addr"` 17 | } 18 | 19 | type Tracing struct { 20 | OtelCollectorHTTP bool `json:"otel_collector_http" yaml:"otel_collector_http"` 21 | OtelStdoutEnabled bool `json:"otel_stdout_enabled" yaml:"otel_stdout_enabled"` 22 | } 23 | 24 | type Factorio struct { 25 | RCONHost string `json:"rcon_host" yaml:"rcon_host"` 26 | RCONPort string `json:"rcon_port" yaml:"rcon_port"` 27 | RCONPassword string `json:"rcon_password" yaml:"rcon_password"` 28 | } 29 | 30 | type Config struct { 31 | meta.Meta `json:"-" yaml:"-"` 32 | 33 | Env string `json:"env" yaml:"env"` 34 | Tracing Tracing `json:"tracing" yaml:"tracing"` 35 | APIServer APIServer `json:"api_server" yaml:"api_server"` 36 | Factorio Factorio `json:"factorio" yaml:"factorio"` 37 | } 38 | 39 | func defaultConfig() Config { 40 | return Config{ 41 | Tracing: Tracing{ 42 | OtelCollectorHTTP: false, 43 | OtelStdoutEnabled: false, 44 | }, 45 | APIServer: APIServer{ 46 | GrpcServerAddr: ":24181", 47 | HTTPServerAddr: ":24180", 48 | }, 49 | Factorio: Factorio{ 50 | RCONHost: "127.0.0.1", 51 | RCONPort: "27015", 52 | RCONPassword: "", 53 | }, 54 | } 55 | } 56 | 57 | func NewConfig(namespace string, app string, configFilePath string, envFilePath string) func() (*Config, error) { 58 | return func() (*Config, error) { 59 | configPath := getConfigFilePath(configFilePath) 60 | 61 | lo.Must0(viper.BindEnv("env")) 62 | 63 | lo.Must0(viper.BindEnv("tracing.otel_collector_http")) 64 | lo.Must0(viper.BindEnv("tracing.otel_stdout_enabled")) 65 | 66 | lo.Must0(viper.BindEnv("api_server.grpc_server_bind")) 67 | lo.Must0(viper.BindEnv("api_server.http_server_bind")) 68 | 69 | lo.Must0(viper.BindEnv("factorio.rcon_host")) 70 | lo.Must0(viper.BindEnv("factorio.rcon_port")) 71 | lo.Must0(viper.BindEnv("factorio.rcon_password")) 72 | 73 | err := loadEnvConfig(envFilePath) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | err = readConfig(configPath) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | config := defaultConfig() 84 | 85 | err = viper.Unmarshal(&config, func(c *mapstructure.DecoderConfig) { 86 | c.TagName = "yaml" 87 | }) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | // For debugging for users, otherwise it's hard to know what's wrong with the config 93 | fmt.Println("read config:") //nolint:forbidigo 94 | xo.PrintJSON(config) 95 | fmt.Println("") //nolint:forbidigo 96 | 97 | meta.Env = config.Env 98 | if meta.Env == "" { 99 | meta.Env = os.Getenv("ENV") 100 | } 101 | 102 | config.Env = meta.Env 103 | config.App = app 104 | config.Namespace = namespace 105 | 106 | return &config, nil 107 | } 108 | } 109 | 110 | func NewTestConfig(envFilePath string) (*Config, error) { 111 | configPath := tryToMatchConfigPathForUnitTest("") 112 | 113 | if envFilePath != "" { 114 | err := loadEnvConfig("") 115 | if err != nil { 116 | return nil, err 117 | } 118 | } 119 | 120 | err := readConfig(configPath) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | config := defaultConfig() 126 | config.Env = "test" 127 | 128 | err = viper.Unmarshal(&config, func(c *mapstructure.DecoderConfig) { 129 | c.TagName = "yaml" 130 | }) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | return &config, nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/grpc/servers/factorioapi/apiserver/grpc_gateway.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "context" 5 | "encoding/gob" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/alexliesenfeld/health" 12 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 13 | "github.com/labstack/echo/v4" 14 | "github.com/nekomeowww/xo/logger" 15 | "github.com/samber/lo" 16 | "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 17 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 18 | "go.uber.org/fx" 19 | "go.uber.org/zap" 20 | "google.golang.org/grpc" 21 | "google.golang.org/grpc/credentials/insecure" 22 | 23 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/configs" 24 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers/interceptors" 25 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers/middlewares" 26 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/libs" 27 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon" 28 | grpcpkg "github.com/nekomeowww/factorio-rcon-api/v2/pkg/grpc" 29 | httppkg "github.com/nekomeowww/factorio-rcon-api/v2/pkg/http" 30 | ) 31 | 32 | type NewGatewayServerParams struct { 33 | fx.In 34 | 35 | Lifecycle fx.Lifecycle 36 | Config *configs.Config 37 | Register *grpcpkg.Register 38 | Logger *logger.Logger 39 | Otel *libs.Otel 40 | RCON rcon.RCON 41 | } 42 | 43 | type GatewayServer struct { 44 | ListenAddr string 45 | GRPCServerAddr string 46 | 47 | echo *echo.Echo 48 | server *http.Server 49 | } 50 | 51 | func NewGatewayServer() func(params NewGatewayServerParams) (*GatewayServer, error) { 52 | return func(params NewGatewayServerParams) (*GatewayServer, error) { 53 | gob.Register(map[interface{}]interface{}{}) 54 | 55 | e := echo.New() 56 | e.RouteNotFound("/*", middlewares.NotFound) 57 | 58 | e.GET("/apis/docs/v1", middlewares.ScalarDocumentation("Factorio RCON API")) 59 | e.GET("/apis/docs/v2", middlewares.ScalarDocumentation("Factorio RCON API")) 60 | 61 | e.GET("/healthz", middlewares.HealthCheck( 62 | health.WithCheck(health.Check{ 63 | Name: "factorio rcon connection", 64 | Check: func(ctx context.Context) error { 65 | return lo.Ternary(params.RCON.IsReady(), nil, fmt.Errorf("rcon connection is not available")) 66 | }, 67 | }), 68 | )) 69 | 70 | for path, methodHandlers := range params.Register.EchoHandlers { 71 | for method, handler := range methodHandlers { 72 | e.Add(method, path, handler) 73 | } 74 | } 75 | 76 | server := &GatewayServer{ 77 | ListenAddr: params.Config.APIServer.HTTPServerAddr, 78 | GRPCServerAddr: params.Config.APIServer.GrpcServerAddr, 79 | echo: e, 80 | server: &http.Server{ 81 | Addr: params.Config.APIServer.HTTPServerAddr, 82 | ReadHeaderTimeout: time.Duration(30) * time.Second, 83 | }, 84 | } 85 | if params.Config.Tracing.OtelCollectorHTTP { 86 | server.server.Handler = otelhttp.NewHandler(e, "") 87 | } else { 88 | server.server.Handler = e 89 | } 90 | 91 | params.Lifecycle.Append(fx.Hook{ 92 | OnStart: func(ctx context.Context) error { 93 | conn, err := grpc.NewClient( 94 | params.Config.APIServer.GrpcServerAddr, 95 | grpc.WithTransportCredentials(insecure.NewCredentials()), 96 | grpc.WithStatsHandler(otelgrpc.NewClientHandler()), 97 | ) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | gateway, err := grpcpkg.NewGateway(ctx, conn, params.Logger, 103 | grpcpkg.WithServerMuxOptions( 104 | runtime.WithErrorHandler(interceptors.HTTPErrorHandler(params.Logger)), 105 | runtime.WithMetadata(interceptors.MetadataRequestPath()), 106 | ), 107 | grpcpkg.WithHandlers(params.Register.HTTPHandlers...), 108 | ) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | if params.Config.Tracing.OtelCollectorHTTP { 114 | server.echo.Any("/api/*", echo.WrapHandler(httppkg.NewTraceparentWrapper(gateway))) 115 | } else { 116 | server.echo.Any("/api/*", echo.WrapHandler(gateway)) 117 | } 118 | 119 | return nil 120 | }, 121 | }) 122 | 123 | return server, nil 124 | } 125 | } 126 | 127 | func RunGatewayServer() func(logger *logger.Logger, server *GatewayServer) error { 128 | return func(logger *logger.Logger, server *GatewayServer) error { 129 | logger.Info("starting http server...") 130 | 131 | listener, err := net.Listen("tcp", server.ListenAddr) 132 | if err != nil { 133 | return fmt.Errorf("failed to listen %s: %v", server.ListenAddr, err) 134 | } 135 | 136 | go func() { 137 | err = server.server.Serve(listener) 138 | if err != nil && err != http.ErrServerClosed { 139 | logger.Fatal(err.Error()) 140 | } 141 | }() 142 | 143 | logger.Info("http server listening...", zap.String("addr", server.ListenAddr)) 144 | 145 | return nil 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /internal/grpc/servers/factorioapi/apiserver/grpc_server.go: -------------------------------------------------------------------------------- 1 | package apiserver 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/nekomeowww/xo/logger" 8 | grpcotel "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" 9 | "go.uber.org/fx" 10 | "go.uber.org/zap" 11 | "google.golang.org/grpc" 12 | 13 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/configs" 14 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers/interceptors" 15 | grpcpkg "github.com/nekomeowww/factorio-rcon-api/v2/pkg/grpc" 16 | ) 17 | 18 | type NewGRPCServerParams struct { 19 | fx.In 20 | 21 | Lifecycle fx.Lifecycle 22 | Config *configs.Config 23 | Logger *logger.Logger 24 | Register *grpcpkg.Register 25 | } 26 | 27 | type GRPCServer struct { 28 | ListenAddr string 29 | 30 | server *grpc.Server 31 | register *grpcpkg.Register 32 | } 33 | 34 | func NewGRPCServer() func(params NewGRPCServerParams) *GRPCServer { 35 | return func(params NewGRPCServerParams) *GRPCServer { 36 | server := grpc.NewServer( 37 | grpc.StatsHandler(grpcotel.NewServerHandler()), 38 | grpc.ChainUnaryInterceptor( 39 | interceptors.PanicInterceptor(params.Logger), 40 | ), 41 | ) 42 | 43 | params.Lifecycle.Append(fx.Hook{ 44 | OnStop: func(ctx context.Context) error { 45 | params.Logger.Info("gracefully shutting down gRPC server...") 46 | server.GracefulStop() 47 | return nil 48 | }, 49 | }) 50 | 51 | return &GRPCServer{ 52 | ListenAddr: params.Config.APIServer.GrpcServerAddr, 53 | server: server, 54 | register: params.Register, 55 | } 56 | } 57 | } 58 | 59 | func RunGRPCServer() func(logger *logger.Logger, server *GRPCServer) error { 60 | return func(logger *logger.Logger, server *GRPCServer) error { 61 | for _, serviceRegister := range server.register.GrpcServices { 62 | serviceRegister(server.server) 63 | } 64 | 65 | l, err := net.Listen("tcp", server.ListenAddr) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | go func() { 71 | err := server.server.Serve(l) 72 | if err != nil && err != grpc.ErrServerStopped { 73 | logger.Fatal("failed to serve gRPC server", zap.Error(err)) 74 | } 75 | }() 76 | 77 | logger.Info("gRPC server started", zap.String("listen_addr", server.ListenAddr)) 78 | 79 | return nil 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/grpc/servers/interceptors/authorization.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 9 | "google.golang.org/grpc/metadata" 10 | ) 11 | 12 | func MetadataAuthorization() func(context.Context, *http.Request) metadata.MD { 13 | return func(ctx context.Context, r *http.Request) metadata.MD { 14 | md := metadata.MD{} 15 | 16 | authorization := r.Header.Get("Authorization") 17 | if authorization != "" { 18 | md.Append("header-authorization", authorization) 19 | } 20 | 21 | return md 22 | } 23 | } 24 | 25 | func AuthorizationFromMetadata(md metadata.MD) (string, error) { 26 | values := md.Get("header-authorization") 27 | if len(values) == 0 { 28 | return "", nil 29 | } 30 | 31 | return values[0], nil 32 | } 33 | 34 | func AuthorizationFromContext(ctx context.Context) (string, error) { 35 | md, ok := metadata.FromIncomingContext(ctx) 36 | if !ok { 37 | return "", apierrors.NewErrInternal().WithError(errors.New("failed to get metadata from context")).WithCaller().AsStatus() 38 | } 39 | 40 | return AuthorizationFromMetadata(md) 41 | } 42 | -------------------------------------------------------------------------------- /internal/grpc/servers/interceptors/cookie.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 11 | "github.com/nekomeowww/fo" 12 | "google.golang.org/grpc/metadata" 13 | ) 14 | 15 | func MetadataCookie() func(context.Context, *http.Request) metadata.MD { 16 | return func(ctx context.Context, r *http.Request) metadata.MD { 17 | md := metadata.MD{} 18 | 19 | for _, cookie := range r.Cookies() { 20 | md.Append("header-cookie-"+cookie.Name, string(fo.May(json.Marshal(http.Cookie{ 21 | Name: cookie.Name, 22 | Value: cookie.Value, 23 | Path: cookie.Path, 24 | Domain: cookie.Domain, 25 | Expires: cookie.Expires, 26 | RawExpires: cookie.RawExpires, 27 | MaxAge: cookie.MaxAge, 28 | Secure: cookie.Secure, 29 | HttpOnly: cookie.HttpOnly, 30 | SameSite: cookie.SameSite, 31 | Raw: cookie.Raw, 32 | Unparsed: cookie.Unparsed, 33 | })))) 34 | } 35 | 36 | return md 37 | } 38 | } 39 | 40 | type Cookies []*http.Cookie 41 | 42 | func (c Cookies) Cookie(name string) *http.Cookie { 43 | for _, cookie := range c { 44 | if cookie.Name == name { 45 | return cookie 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func CookiesFromContext(ctx context.Context) (Cookies, error) { 53 | md, ok := metadata.FromIncomingContext(ctx) 54 | if !ok { 55 | return nil, apierrors.NewErrInternal().WithError(errors.New("failed to get metadata from context")).WithCaller().AsStatus() 56 | } 57 | 58 | return CookiesFromMetadata(md) 59 | } 60 | 61 | func CookiesFromMetadata(md metadata.MD) (Cookies, error) { 62 | var cookies Cookies 63 | 64 | for k, v := range md { 65 | if len(v) == 0 { 66 | continue 67 | } 68 | if strings.HasPrefix(k, "header-cookie-") { 69 | var cookie http.Cookie 70 | 71 | err := json.Unmarshal([]byte(v[0]), &cookie) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | cookies = append(cookies, &cookie) 77 | } 78 | } 79 | 80 | return cookies, nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/grpc/servers/interceptors/error_handler.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 10 | "github.com/nekomeowww/factorio-rcon-api/v2/apis/jsonapi" 11 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 12 | "github.com/nekomeowww/xo/logger" 13 | "go.uber.org/zap" 14 | "google.golang.org/grpc/codes" 15 | "google.golang.org/grpc/status" 16 | ) 17 | 18 | func handleStatusError(s *status.Status, logger *logger.Logger, err error) *apierrors.ErrResponse { 19 | switch s.Code() { //nolint 20 | case codes.InvalidArgument: 21 | if len(s.Details()) > 0 { 22 | break 23 | } 24 | 25 | return apierrors.NewErrInvalidArgument().WithDetail(s.Message()).AsResponse() 26 | case codes.Unimplemented: 27 | logger.Error("unimplemented error", zap.Error(err)) 28 | return apierrors.NewErrNotFound().WithDetail("route not found or method not allowed").AsResponse() 29 | case codes.Internal: 30 | var errorCaller *jsonapi.ErrorCaller 31 | 32 | if len(s.Details()) > 1 { 33 | errorCaller, _ = s.Details()[1].(*jsonapi.ErrorCaller) 34 | } 35 | 36 | fields := []zap.Field{zap.Error(err)} 37 | if errorCaller != nil { 38 | fields = append(fields, zap.String("file", fmt.Sprintf("%s:%d", errorCaller.GetFile(), errorCaller.GetLine()))) 39 | fields = append(fields, zap.String("function", errorCaller.GetFunction())) 40 | } 41 | 42 | logger.Error("internal error", fields...) 43 | 44 | return apierrors.NewErrInternal().AsResponse() 45 | case codes.NotFound: 46 | if len(s.Details()) > 0 { 47 | break 48 | } 49 | 50 | logger.Error("unimplemented error", zap.Error(err)) 51 | 52 | return apierrors.NewErrNotFound().WithDetail("route not found or method not allowed").AsResponse() 53 | case codes.DeadlineExceeded: 54 | if len(s.Details()) > 0 { 55 | break 56 | } 57 | 58 | return apierrors.NewErrTimeout().AsResponse() 59 | default: 60 | break 61 | } 62 | 63 | errResp := apierrors.NewErrResponse() 64 | if len(s.Details()) > 0 { 65 | detail, ok := s.Details()[0].(*jsonapi.ErrorObject) 66 | if ok { 67 | errResp = errResp.WithError(&apierrors.Error{ 68 | ErrorObject: detail, 69 | }) 70 | } 71 | } 72 | 73 | return errResp 74 | } 75 | 76 | func handleError(logger *logger.Logger, err error) *apierrors.ErrResponse { 77 | if s, ok := status.FromError(err); ok { 78 | return handleStatusError(s, logger, err) 79 | } 80 | 81 | logger.Error("unknown error (probably unhandled)", zap.Error(err)) 82 | 83 | return apierrors.NewErrInternal().AsResponse() 84 | } 85 | 86 | func HTTPErrorHandler(logger *logger.Logger) func(ctx context.Context, _ *runtime.ServeMux, _ runtime.Marshaler, writer http.ResponseWriter, _ *http.Request, err error) { 87 | return func(ctx context.Context, _ *runtime.ServeMux, _ runtime.Marshaler, writer http.ResponseWriter, _ *http.Request, err error) { 88 | if err != nil { 89 | errResp := handleError(logger, err) 90 | 91 | b, _ := json.Marshal(errResp) 92 | 93 | writer.Header().Set("Content-Type", "application/json") 94 | writer.WriteHeader(errResp.HttpStatus()) 95 | 96 | _, _ = writer.Write(b) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/grpc/servers/interceptors/panic.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "runtime/debug" 6 | 7 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 8 | "github.com/nekomeowww/xo/logger" 9 | "go.uber.org/zap" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | func PanicInterceptor(logger *logger.Logger) grpc.UnaryServerInterceptor { 14 | return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { //nolint:nonamedreturns 15 | defer func() { 16 | r := recover() 17 | if r != nil { 18 | logger.Error("panicked", zap.Any("err", r), zap.Stack(string(debug.Stack()))) 19 | err = apierrors.NewErrInternal().AsStatus() 20 | resp = nil 21 | } 22 | }() 23 | 24 | return handler(ctx, req) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/grpc/servers/interceptors/request_path.go: -------------------------------------------------------------------------------- 1 | package interceptors 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "google.golang.org/grpc/metadata" 8 | ) 9 | 10 | func MetadataRequestPath() func(ctx context.Context, request *http.Request) metadata.MD { 11 | return func(ctx context.Context, request *http.Request) metadata.MD { 12 | return metadata.New(map[string]string{ 13 | "path": request.URL.Path, 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/grpc/servers/middlewares/health.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/alexliesenfeld/health" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func HealthCheck(checkOptions ...health.CheckerOption) echo.HandlerFunc { 11 | return func(c echo.Context) error { 12 | opts := make([]health.CheckerOption, 0) 13 | opts = append(opts, 14 | health.WithCacheDuration(time.Second), 15 | health.WithTimeout(time.Second*10), //nolint:mnd 16 | ) 17 | 18 | checker := health.NewChecker(opts...) 19 | handler := health.NewHandler(checker) 20 | 21 | handler.ServeHTTP(c.Response().Writer, c.Request()) 22 | 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/grpc/servers/middlewares/response_log.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/nekomeowww/xo/logger" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func ResponseLog(logger *logger.Logger) echo.MiddlewareFunc { 12 | return func(next echo.HandlerFunc) echo.HandlerFunc { 13 | return func(c echo.Context) error { 14 | start := time.Now() 15 | 16 | err := next(c) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | end := time.Now() 22 | 23 | logger.Debug("", 24 | zap.String("latency", end.Sub(start).String()), 25 | zap.String("path", c.Request().RequestURI), 26 | zap.String("remote", c.Request().RemoteAddr), 27 | zap.String("hosts", c.Request().URL.Host), 28 | zap.Int("status", c.Response().Status), 29 | zap.String("method", c.Request().Method), 30 | ) 31 | 32 | return nil 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/grpc/servers/middlewares/route_not_found.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 8 | ) 9 | 10 | func NotFound(c echo.Context) error { 11 | return c.JSON(http.StatusNotFound, apierrors.NewErrNotFound().AsResponse()) 12 | } 13 | -------------------------------------------------------------------------------- /internal/grpc/servers/middlewares/scalar.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/MarceloPetrucio/go-scalar-api-reference" 7 | "github.com/labstack/echo/v4" 8 | v1 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v1" 9 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 10 | ) 11 | 12 | func ScalarDocumentation(title string) echo.HandlerFunc { 13 | return func(c echo.Context) error { 14 | content, err := scalar.ApiReferenceHTML(&scalar.Options{ 15 | SpecContent: string(v1.OpenAPIV3SpecYaml()), 16 | CustomOptions: scalar.CustomOptions{ 17 | PageTitle: title, 18 | }, 19 | }) 20 | if err != nil { 21 | return apierrors.NewErrInternal().WithError(err).WithDetailf("failed to generate API documentation: %s", err.Error()).AsEchoResponse(c) 22 | } 23 | 24 | return c.HTML(http.StatusOK, content) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/grpc/servers/middlewares/static.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func StaticWithBytes(specBytes []byte, contentType string) echo.HandlerFunc { 10 | return func(c echo.Context) error { 11 | return c.Blob(http.StatusOK, contentType, specBytes) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/grpc/servers/servers.go: -------------------------------------------------------------------------------- 1 | package servers 2 | 3 | import ( 4 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/servers/factorioapi/apiserver" 5 | "go.uber.org/fx" 6 | ) 7 | 8 | func Modules() fx.Option { 9 | return fx.Options( 10 | fx.Provide(apiserver.NewGRPCServer()), 11 | fx.Provide(apiserver.NewGatewayServer()), 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /internal/grpc/services/factorioapi/factorioapi.go: -------------------------------------------------------------------------------- 1 | package factorioapi 2 | 3 | import ( 4 | factorioapiv1 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v1" 5 | factorioapiv2 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v2" 6 | consolev1 "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/services/factorioapi/v1/console" 7 | consolev2 "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/services/factorioapi/v2/console" 8 | grpcpkg "github.com/nekomeowww/factorio-rcon-api/v2/pkg/grpc" 9 | "go.uber.org/fx" 10 | "google.golang.org/grpc/reflection" 11 | ) 12 | 13 | func Modules() fx.Option { 14 | return fx.Options( 15 | fx.Provide(NewFactorioAPI()), 16 | fx.Provide(consolev1.NewConsoleService()), 17 | fx.Provide(consolev2.NewConsoleService()), 18 | ) 19 | } 20 | 21 | type NewFactorioAPIParams struct { 22 | fx.In 23 | 24 | Console *consolev1.ConsoleService 25 | ConsoleV2 *consolev2.ConsoleService 26 | } 27 | 28 | type FactorioAPI struct { 29 | params *NewFactorioAPIParams 30 | } 31 | 32 | func NewFactorioAPI() func(params NewFactorioAPIParams) *FactorioAPI { 33 | return func(params NewFactorioAPIParams) *FactorioAPI { 34 | return &FactorioAPI{params: ¶ms} 35 | } 36 | } 37 | 38 | func (c *FactorioAPI) Register(r *grpcpkg.Register) { 39 | r.RegisterHTTPHandlers([]grpcpkg.HTTPHandler{ 40 | factorioapiv1.RegisterConsoleServiceHandler, 41 | factorioapiv2.RegisterConsoleServiceHandler, 42 | }) 43 | 44 | r.RegisterGrpcService(func(s reflection.GRPCServer) { 45 | factorioapiv1.RegisterConsoleServiceServer(s, c.params.Console) 46 | factorioapiv2.RegisterConsoleServiceServer(s, c.params.ConsoleV2) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /internal/grpc/services/factorioapi/v1/console/console.go: -------------------------------------------------------------------------------- 1 | package consolev1 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | v1 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v1" 11 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon" 12 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 13 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/utils" 14 | "github.com/nekomeowww/xo/logger" 15 | "github.com/samber/lo" 16 | "go.uber.org/fx" 17 | "go.uber.org/zap" 18 | "google.golang.org/grpc/codes" 19 | "google.golang.org/grpc/status" 20 | ) 21 | 22 | type NewConsoleServiceParams struct { 23 | fx.In 24 | 25 | Logger *logger.Logger 26 | RCON rcon.RCON 27 | } 28 | 29 | type ConsoleService struct { 30 | v1.UnimplementedConsoleServiceServer 31 | 32 | logger *logger.Logger 33 | rcon rcon.RCON 34 | } 35 | 36 | func NewConsoleService() func(NewConsoleServiceParams) *ConsoleService { 37 | return func(params NewConsoleServiceParams) *ConsoleService { 38 | return &ConsoleService{ 39 | logger: params.Logger, 40 | rcon: params.RCON, 41 | } 42 | } 43 | } 44 | 45 | func (s *ConsoleService) CommandRaw(ctx context.Context, req *v1.CommandRawRequest) (*v1.CommandRawResponse, error) { 46 | resp, err := s.rcon.Execute(ctx, req.Input) 47 | if err != nil { 48 | if errors.Is(err, rcon.ErrTimeout) { 49 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 50 | } 51 | 52 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 53 | } 54 | 55 | return &v1.CommandRawResponse{ 56 | Output: resp, 57 | }, nil 58 | } 59 | 60 | func (s *ConsoleService) CommandMessage(ctx context.Context, req *v1.CommandMessageRequest) (*v1.CommandMessageResponse, error) { 61 | if req.Message == "" { 62 | return nil, apierrors.NewErrInvalidArgument().WithDetail("message should not be empty").AsStatus() 63 | } 64 | 65 | resp, err := s.rcon.Execute(ctx, req.Message) 66 | if err != nil { 67 | if errors.Is(err, rcon.ErrTimeout) { 68 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 69 | } 70 | 71 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 72 | } 73 | 74 | s.logger.Info("executed command message and got response", zap.String("response", resp), zap.String("message", req.Message)) 75 | 76 | return &v1.CommandMessageResponse{}, nil 77 | } 78 | 79 | func (s *ConsoleService) CommandAlerts(ctx context.Context, req *v1.CommandAlertsRequest) (*v1.CommandAlertsResponse, error) { 80 | return nil, status.Errorf(codes.Unimplemented, "method CommandAlerts not implemented") 81 | } 82 | 83 | func (s *ConsoleService) CommandEnableResearchQueue(ctx context.Context, req *v1.CommandEnableResearchQueueRequest) (*v1.CommandEnableResearchQueueResponse, error) { 84 | return nil, status.Errorf(codes.Unimplemented, "method CommandEnableResearchQueue not implemented") 85 | } 86 | 87 | func (s *ConsoleService) CommandMuteProgrammableSpeakerForEveryone(ctx context.Context, req *v1.CommandMuteProgrammableSpeakerForEveryoneRequest) (*v1.CommandMuteProgrammableSpeakerForEveryoneResponse, error) { 88 | return nil, status.Errorf(codes.Unimplemented, "method CommandMuteProgrammableSpeakerForEveryone not implemented") 89 | } 90 | 91 | func (s *ConsoleService) CommandUnmuteProgrammableSpeakerForEveryone(ctx context.Context, req *v1.CommandUnmuteProgrammableSpeakerForEveryoneRequest) (*v1.CommandUnmuteProgrammableSpeakerForEveryoneResponse, error) { 92 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnmuteProgrammableSpeakerForEveryone not implemented") 93 | } 94 | 95 | func (s *ConsoleService) CommandPermissions(ctx context.Context, req *v1.CommandPermissionsRequest) (*v1.CommandPermissionsResponse, error) { 96 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissions not implemented") 97 | } 98 | 99 | func (s *ConsoleService) CommandPermissionsAddPlayer(ctx context.Context, req *v1.CommandPermissionsAddPlayerRequest) (*v1.CommandPermissionsAddPlayerResponse, error) { 100 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsAddPlayer not implemented") 101 | } 102 | 103 | func (s *ConsoleService) CommandPermissionsCreateGroup(ctx context.Context, req *v1.CommandPermissionsCreateGroupRequest) (*v1.CommandPermissionsCreateGroupResponse, error) { 104 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsCreateGroup not implemented") 105 | } 106 | 107 | func (s *ConsoleService) CommandPermissionsDeleteGroup(ctx context.Context, req *v1.CommandPermissionsDeleteGroupRequest) (*v1.CommandPermissionsDeleteGroupResponse, error) { 108 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsDeleteGroup not implemented") 109 | } 110 | 111 | func (s *ConsoleService) CommandPermissionsEditGroup(ctx context.Context, req *v1.CommandPermissionsEditGroupRequest) (*v1.CommandPermissionsEditGroupResponse, error) { 112 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsEditGroup not implemented") 113 | } 114 | 115 | func (s *ConsoleService) CommandPermissionsGetPlayerGroup(ctx context.Context, req *v1.CommandPermissionsGetPlayerGroupRequest) (*v1.CommandPermissionsGetPlayerGroupResponse, error) { 116 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsGetPlayerGroup not implemented") 117 | } 118 | 119 | func (s *ConsoleService) CommandPermissionsRemovePlayerGroup(ctx context.Context, req *v1.CommandPermissionsRemovePlayerGroupRequest) (*v1.CommandPermissionsRemovePlayerGroupResponse, error) { 120 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsRemovePlayerGroup not implemented") 121 | } 122 | 123 | func (s *ConsoleService) CommandPermissionsRenameGroup(ctx context.Context, req *v1.CommandPermissionsRenameGroupRequest) (*v1.CommandPermissionsRenameGroupResponse, error) { 124 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsRenameGroup not implemented") 125 | } 126 | 127 | func (s *ConsoleService) CommandResetTips(ctx context.Context, req *v1.CommandResetTipsRequest) (*v1.CommandResetTipsResponse, error) { 128 | return nil, status.Errorf(codes.Unimplemented, "method CommandResetTips not implemented") 129 | } 130 | 131 | var ( 132 | regexpEvolutionFactor = regexp.MustCompile(`Evolution factor: ([0-9.]+)\. \(Time ([0-9.]+)%\) \(Pollution ([0-9.]+)%\) \(Spawner kills ([0-9.]+)%\)`) 133 | ) 134 | 135 | func (s *ConsoleService) CommandEvolution(ctx context.Context, req *v1.CommandEvolutionRequest) (*v1.CommandEvolutionResponse, error) { 136 | resp, err := s.rcon.Execute(ctx, "/evolution") 137 | if err != nil { 138 | if errors.Is(err, rcon.ErrTimeout) { 139 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 140 | } 141 | 142 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 143 | } 144 | 145 | normalized := strings.TrimSuffix(resp, "\n") 146 | 147 | // output: Evolution factor: 0.0000. (Time 0%) (Pollution 0%) (Spawner kills 0%) 148 | match := regexpEvolutionFactor.FindStringSubmatch(resp) 149 | if len(match) != 5 { 150 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse evolution factor: %s due to matches not equals 5", normalized).AsStatus() 151 | } 152 | 153 | // match[1] is the evolution factor 154 | evolutionFactor, err := strconv.ParseFloat(match[1], 64) 155 | if err != nil { 156 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse evolution factor: %s from %s due to %v", match[1], normalized, err).AsStatus() 157 | } 158 | 159 | // match[2] is the time 160 | time, err := strconv.ParseFloat(match[2], 64) 161 | if err != nil { 162 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse time: %s from %s due to %v", match[2], normalized, err).AsStatus() 163 | } 164 | 165 | // match[3] is the pollution 166 | pollution, err := strconv.ParseFloat(match[3], 64) 167 | if err != nil { 168 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse pollution: %s from %s due to %v", match[3], normalized, err).AsStatus() 169 | } 170 | 171 | // match[4] is the spawner kills 172 | spawnerKills, err := strconv.ParseFloat(match[4], 64) 173 | if err != nil { 174 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse spawner kills: %s from %s due to %v", match[4], normalized, err).AsStatus() 175 | } 176 | 177 | return &v1.CommandEvolutionResponse{ 178 | EvolutionFactor: evolutionFactor, 179 | Time: time, 180 | Pollution: pollution, 181 | SpawnerKills: spawnerKills, 182 | }, nil 183 | } 184 | 185 | func (s *ConsoleService) CommandSeed(ctx context.Context, req *v1.CommandSeedRequest) (*v1.CommandSeedResponse, error) { 186 | resp, err := s.rcon.Execute(ctx, "/seed") 187 | if err != nil { 188 | if errors.Is(err, rcon.ErrTimeout) { 189 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 190 | } 191 | 192 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 193 | } 194 | 195 | normalized := strings.TrimSuffix(resp, "\n") 196 | if normalized == "" { 197 | return &v1.CommandSeedResponse{}, nil 198 | } 199 | 200 | return &v1.CommandSeedResponse{ 201 | Seed: normalized, 202 | }, nil 203 | } 204 | 205 | func (s *ConsoleService) CommandTime(ctx context.Context, req *v1.CommandTimeRequest) (*v1.CommandTimeResponse, error) { 206 | resp, err := s.rcon.Execute(ctx, "/time") 207 | if err != nil { 208 | if errors.Is(err, rcon.ErrTimeout) { 209 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 210 | } 211 | 212 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 213 | } 214 | 215 | normalized := strings.TrimSuffix(resp, "\n") 216 | if normalized == "" { 217 | return &v1.CommandTimeResponse{}, nil 218 | } 219 | 220 | duration, err := utils.ParseDuration(normalized) 221 | if err != nil { 222 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 223 | } 224 | 225 | return &v1.CommandTimeResponse{ 226 | Time: duration.Seconds(), 227 | }, nil 228 | } 229 | 230 | func (s *ConsoleService) CommandToggleActionLogging(ctx context.Context, req *v1.CommandToggleActionLoggingRequest) (*v1.CommandToggleActionLoggingResponse, error) { 231 | return nil, status.Errorf(codes.Unimplemented, "method CommandToggleActionLogging not implemented") 232 | } 233 | 234 | func (s *ConsoleService) CommandToggleHeavyMode(ctx context.Context, req *v1.CommandToggleHeavyModeRequest) (*v1.CommandToggleHeavyModeResponse, error) { 235 | return nil, status.Errorf(codes.Unimplemented, "method CommandToggleHeavyMode not implemented") 236 | } 237 | 238 | func (s *ConsoleService) CommandUnlockShortcutBar(ctx context.Context, req *v1.CommandUnlockShortcutBarRequest) (*v1.CommandUnlockShortcutBarResponse, error) { 239 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnlockShortcutBar not implemented") 240 | } 241 | 242 | func (s *ConsoleService) CommandUnlockTips(ctx context.Context, req *v1.CommandUnlockTipsRequest) (*v1.CommandUnlockTipsResponse, error) { 243 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnlockTips not implemented") 244 | } 245 | 246 | func (s *ConsoleService) CommandVersion(ctx context.Context, req *v1.CommandVersionRequest) (*v1.CommandVersionResponse, error) { 247 | resp, err := s.rcon.Execute(ctx, "/version") 248 | if err != nil { 249 | if errors.Is(err, rcon.ErrTimeout) { 250 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 251 | } 252 | 253 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 254 | } 255 | 256 | return &v1.CommandVersionResponse{ 257 | Version: strings.TrimSuffix(resp, "\n"), 258 | }, nil 259 | } 260 | 261 | func (s *ConsoleService) CommandAdmins(ctx context.Context, req *v1.CommandAdminsRequest) (*v1.CommandAdminsResponse, error) { 262 | resp, err := s.rcon.Execute(ctx, "/admins") 263 | if err != nil { 264 | if errors.Is(err, rcon.ErrTimeout) { 265 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 266 | } 267 | 268 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 269 | } 270 | 271 | admins, err := utils.StringListToPlayers(resp) 272 | if err != nil { 273 | return nil, err 274 | } 275 | 276 | return &v1.CommandAdminsResponse{ 277 | Admins: admins, 278 | }, nil 279 | } 280 | 281 | func (s *ConsoleService) CommandBan(ctx context.Context, req *v1.CommandBanRequest) (*v1.CommandBanResponse, error) { 282 | if req.Username == "" { 283 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 284 | } 285 | 286 | cmd := "/ban " + req.Username 287 | 288 | resp, err := s.rcon.Execute(ctx, cmd) 289 | if err != nil { 290 | if errors.Is(err, rcon.ErrTimeout) { 291 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 292 | } 293 | 294 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 295 | } 296 | 297 | s.logger.Info("executed command ban and got response", zap.String("response", resp), zap.String("username", req.Username)) 298 | 299 | return &v1.CommandBanResponse{}, nil 300 | } 301 | 302 | func (s *ConsoleService) CommandBans(ctx context.Context, req *v1.CommandBansRequest) (*v1.CommandBansResponse, error) { 303 | resp, err := s.rcon.Execute(ctx, "/bans") 304 | if err != nil { 305 | if errors.Is(err, rcon.ErrTimeout) { 306 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 307 | } 308 | 309 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 310 | } 311 | 312 | bans, err := utils.StringListToPlayers(resp) 313 | if err != nil { 314 | return nil, err 315 | } 316 | 317 | return &v1.CommandBansResponse{ 318 | Bans: bans, 319 | }, nil 320 | } 321 | 322 | func (s *ConsoleService) CommandDemote(ctx context.Context, req *v1.CommandDemoteRequest) (*v1.CommandDemoteResponse, error) { 323 | if req.Username == "" { 324 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 325 | } 326 | 327 | resp, err := s.rcon.Execute(ctx, "/demote "+req.Username) 328 | if err != nil { 329 | if errors.Is(err, rcon.ErrTimeout) { 330 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 331 | } 332 | 333 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 334 | } 335 | 336 | s.logger.Info("executed command demote and got response", zap.String("response", resp), zap.String("username", req.Username)) 337 | 338 | return &v1.CommandDemoteResponse{}, nil 339 | } 340 | 341 | func (s *ConsoleService) CommandIgnore(ctx context.Context, req *v1.CommandIgnoreRequest) (*v1.CommandIgnoreResponse, error) { 342 | if req.Username == "" { 343 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 344 | } 345 | 346 | resp, err := s.rcon.Execute(ctx, "/ignore "+req.Username) 347 | if err != nil { 348 | if errors.Is(err, rcon.ErrTimeout) { 349 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 350 | } 351 | 352 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 353 | } 354 | 355 | s.logger.Info("executed command ignore and got response", zap.String("response", resp), zap.String("username", req.Username)) 356 | 357 | return &v1.CommandIgnoreResponse{}, nil 358 | } 359 | 360 | func (s *ConsoleService) CommandKick(ctx context.Context, req *v1.CommandKickRequest) (*v1.CommandKickResponse, error) { 361 | if req.Username == "" { 362 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 363 | } 364 | 365 | cmd := "/kick " + req.Username 366 | if req.Reason != "" { 367 | cmd += " " + req.Reason 368 | } 369 | 370 | resp, err := s.rcon.Execute(ctx, cmd) 371 | if err != nil { 372 | if errors.Is(err, rcon.ErrTimeout) { 373 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 374 | } 375 | 376 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 377 | } 378 | 379 | s.logger.Info("executed command kick and got response", zap.String("response", resp), zap.String("username", req.Username), zap.String("reason", req.Reason)) 380 | 381 | return &v1.CommandKickResponse{}, nil 382 | } 383 | 384 | func (s *ConsoleService) CommandMute(ctx context.Context, req *v1.CommandMuteRequest) (*v1.CommandMuteResponse, error) { 385 | if req.Username == "" { 386 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 387 | } 388 | 389 | resp, err := s.rcon.Execute(ctx, "/mute "+req.Username) 390 | if err != nil { 391 | if errors.Is(err, rcon.ErrTimeout) { 392 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 393 | } 394 | 395 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 396 | } 397 | 398 | s.logger.Info("executed command mute and got response", zap.String("response", resp), zap.String("username", req.Username)) 399 | 400 | return &v1.CommandMuteResponse{}, nil 401 | } 402 | 403 | func (s *ConsoleService) CommandMutes(ctx context.Context, req *v1.CommandMutesRequest) (*v1.CommandMutesResponse, error) { 404 | resp, err := s.rcon.Execute(ctx, "/mutes") 405 | if err != nil { 406 | if errors.Is(err, rcon.ErrTimeout) { 407 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 408 | } 409 | 410 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 411 | } 412 | 413 | mutes, err := utils.StringListToPlayers(resp) 414 | if err != nil { 415 | return nil, err 416 | } 417 | 418 | return &v1.CommandMutesResponse{ 419 | Mutes: mutes, 420 | }, nil 421 | } 422 | 423 | func (s *ConsoleService) CommandPlayers(ctx context.Context, req *v1.CommandPlayersRequest) (*v1.CommandPlayersResponse, error) { 424 | resp, err := s.rcon.Execute(ctx, "/players") 425 | if err != nil { 426 | if errors.Is(err, rcon.ErrTimeout) { 427 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 428 | } 429 | 430 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 431 | } 432 | 433 | lines := strings.Split(resp, "\n") 434 | lines = lines[1 : len(lines)-1] 435 | 436 | players, err := utils.StringListToPlayers(strings.Join(lines, "\n")) 437 | if err != nil { 438 | return nil, err 439 | } 440 | 441 | return &v1.CommandPlayersResponse{ 442 | Players: players, 443 | }, nil 444 | } 445 | 446 | func (s *ConsoleService) CommandPromote(ctx context.Context, req *v1.CommandPromoteRequest) (*v1.CommandPromoteResponse, error) { 447 | if req.Username == "" { 448 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 449 | } 450 | 451 | resp, err := s.rcon.Execute(ctx, "/promote "+req.Username) 452 | if err != nil { 453 | if errors.Is(err, rcon.ErrTimeout) { 454 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 455 | } 456 | 457 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 458 | } 459 | 460 | s.logger.Info("executed command promote and got response", zap.String("response", resp), zap.String("username", req.Username)) 461 | 462 | return &v1.CommandPromoteResponse{}, nil 463 | } 464 | 465 | func (s *ConsoleService) CommandPurge(ctx context.Context, req *v1.CommandPurgeRequest) (*v1.CommandPurgeResponse, error) { 466 | if req.Username == "" { 467 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 468 | } 469 | 470 | resp, err := s.rcon.Execute(ctx, "/purge"+" "+req.Username) 471 | if err != nil { 472 | if errors.Is(err, rcon.ErrTimeout) { 473 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 474 | } 475 | 476 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 477 | } 478 | 479 | s.logger.Info("executed command purge and got response", zap.String("response", resp), zap.String("username", req.Username)) 480 | 481 | return &v1.CommandPurgeResponse{}, nil 482 | } 483 | 484 | func (s *ConsoleService) CommandServerSave(ctx context.Context, req *v1.CommandServerSaveRequest) (*v1.CommandServerSaveResponse, error) { 485 | resp, err := s.rcon.Execute(ctx, "/server-save") 486 | if err != nil { 487 | if errors.Is(err, rcon.ErrTimeout) { 488 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 489 | } 490 | 491 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 492 | } 493 | 494 | s.logger.Info("executed command server-save and got response", zap.String("response", resp)) 495 | 496 | return &v1.CommandServerSaveResponse{}, nil 497 | } 498 | 499 | func (s *ConsoleService) CommandUnban(ctx context.Context, req *v1.CommandUnbanRequest) (*v1.CommandUnbanResponse, error) { 500 | if req.Username == "" { 501 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 502 | } 503 | 504 | resp, err := s.rcon.Execute(ctx, "/unban "+req.Username) 505 | if err != nil { 506 | if errors.Is(err, rcon.ErrTimeout) { 507 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 508 | } 509 | 510 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 511 | } 512 | 513 | s.logger.Info("executed command unban and got response", zap.String("response", resp), zap.String("username", req.Username)) 514 | 515 | return &v1.CommandUnbanResponse{}, nil 516 | } 517 | 518 | func (s *ConsoleService) CommandUnignore(ctx context.Context, req *v1.CommandUnignoreRequest) (*v1.CommandUnignoreResponse, error) { 519 | if req.Username == "" { 520 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 521 | } 522 | 523 | resp, err := s.rcon.Execute(ctx, "/unignore "+req.Username) 524 | if err != nil { 525 | if errors.Is(err, rcon.ErrTimeout) { 526 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 527 | } 528 | 529 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 530 | } 531 | 532 | s.logger.Info("executed command unignore and got response", zap.String("response", resp), zap.String("username", req.Username)) 533 | 534 | return &v1.CommandUnignoreResponse{}, nil 535 | } 536 | 537 | func (s *ConsoleService) CommandUnmute(ctx context.Context, req *v1.CommandUnmuteRequest) (*v1.CommandUnmuteResponse, error) { 538 | if req.Username == "" { 539 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 540 | } 541 | 542 | resp, err := s.rcon.Execute(ctx, "/unmute "+req.Username) 543 | if err != nil { 544 | if errors.Is(err, rcon.ErrTimeout) { 545 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 546 | } 547 | 548 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 549 | } 550 | 551 | s.logger.Info("executed command unmute and got response", zap.String("response", resp), zap.String("username", req.Username)) 552 | 553 | return &v1.CommandUnmuteResponse{}, nil 554 | } 555 | 556 | func (s *ConsoleService) CommandWhisper(ctx context.Context, req *v1.CommandWhisperRequest) (*v1.CommandWhisperResponse, error) { 557 | if req.Username == "" { 558 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 559 | } 560 | if req.Message == "" { 561 | return nil, apierrors.NewErrInvalidArgument().WithDetail("message should not be empty").AsStatus() 562 | } 563 | 564 | resp, err := s.rcon.Execute(ctx, "/whisper "+req.Username+" "+req.Message) 565 | if err != nil { 566 | if errors.Is(err, rcon.ErrTimeout) { 567 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 568 | } 569 | 570 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 571 | } 572 | 573 | s.logger.Info("executed command whisper and got response", zap.String("response", resp), zap.String("username", req.Username), zap.String("message", req.Message)) 574 | 575 | return &v1.CommandWhisperResponse{}, nil 576 | } 577 | 578 | func (s *ConsoleService) CommandWhitelistAdd(ctx context.Context, req *v1.CommandWhitelistAddRequest) (*v1.CommandWhitelistAddResponse, error) { 579 | if req.Username == "" { 580 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 581 | } 582 | 583 | resp, err := s.rcon.Execute(ctx, "/whitelist add "+req.Username) 584 | if err != nil { 585 | if errors.Is(err, rcon.ErrTimeout) { 586 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 587 | } 588 | 589 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 590 | } 591 | 592 | s.logger.Info("executed command whitelist add and got response", zap.String("response", resp), zap.String("username", req.Username)) 593 | 594 | return &v1.CommandWhitelistAddResponse{}, nil 595 | } 596 | 597 | func (s *ConsoleService) CommandWhitelistGet(ctx context.Context, req *v1.CommandWhitelistGetRequest) (*v1.CommandWhitelistGetResponse, error) { 598 | resp, err := s.rcon.Execute(ctx, "/whitelist get") 599 | if err != nil { 600 | if errors.Is(err, rcon.ErrTimeout) { 601 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 602 | } 603 | 604 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 605 | } 606 | 607 | resp = strings.TrimPrefix(resp, "Whitelisted players:") 608 | resp = strings.TrimSpace(resp) 609 | 610 | playerNames := utils.ParseWhitelistedPlayers(resp) 611 | 612 | players := lo.Map(playerNames, func(player string, _ int) *v1.Player { 613 | return &v1.Player{ 614 | Username: player, 615 | } 616 | }) 617 | 618 | savePlayers, err := s.CommandPlayers(ctx, &v1.CommandPlayersRequest{}) 619 | if err != nil { 620 | return nil, err 621 | } 622 | 623 | mPlayers := lo.SliceToMap(savePlayers.Players, func(player *v1.Player) (string, *v1.Player) { 624 | return player.Username, player 625 | }) 626 | 627 | for _, player := range players { 628 | if p, ok := mPlayers[player.Username]; ok { 629 | player.Online = p.Online 630 | } 631 | } 632 | 633 | return &v1.CommandWhitelistGetResponse{ 634 | Whitelist: players, 635 | }, nil 636 | } 637 | 638 | func (s *ConsoleService) CommandWhitelistRemove(ctx context.Context, req *v1.CommandWhitelistRemoveRequest) (*v1.CommandWhitelistRemoveResponse, error) { 639 | if req.Username == "" { 640 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 641 | } 642 | 643 | resp, err := s.rcon.Execute(ctx, "/whitelist remove "+req.Username) 644 | if err != nil { 645 | if errors.Is(err, rcon.ErrTimeout) { 646 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 647 | } 648 | 649 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 650 | } 651 | 652 | s.logger.Info("executed command whitelist remove and got response", zap.String("response", resp), zap.String("username", req.Username)) 653 | 654 | return &v1.CommandWhitelistRemoveResponse{}, nil 655 | } 656 | 657 | func (s *ConsoleService) CommandWhitelistClear(ctx context.Context, req *v1.CommandWhitelistClearRequest) (*v1.CommandWhitelistClearResponse, error) { 658 | resp, err := s.rcon.Execute(ctx, "/whitelist clear") 659 | if err != nil { 660 | if errors.Is(err, rcon.ErrTimeout) { 661 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 662 | } 663 | 664 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 665 | } 666 | 667 | s.logger.Info("executed command whitelist clear and got response", zap.String("response", resp)) 668 | 669 | return &v1.CommandWhitelistClearResponse{}, nil 670 | } 671 | -------------------------------------------------------------------------------- /internal/grpc/services/factorioapi/v2/console/console.go: -------------------------------------------------------------------------------- 1 | package consolev2 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | v2 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v2" 11 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon" 12 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 13 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/utils" 14 | "github.com/nekomeowww/xo/logger" 15 | "github.com/samber/lo" 16 | "go.uber.org/fx" 17 | "go.uber.org/zap" 18 | "google.golang.org/grpc/codes" 19 | "google.golang.org/grpc/status" 20 | ) 21 | 22 | type NewConsoleServiceParams struct { 23 | fx.In 24 | 25 | Logger *logger.Logger 26 | RCON rcon.RCON 27 | } 28 | 29 | type ConsoleService struct { 30 | v2.UnimplementedConsoleServiceServer 31 | 32 | logger *logger.Logger 33 | rcon rcon.RCON 34 | } 35 | 36 | func NewConsoleService() func(NewConsoleServiceParams) *ConsoleService { 37 | return func(params NewConsoleServiceParams) *ConsoleService { 38 | return &ConsoleService{ 39 | logger: params.Logger, 40 | rcon: params.RCON, 41 | } 42 | } 43 | } 44 | 45 | func (s *ConsoleService) CommandRaw(ctx context.Context, req *v2.CommandRawRequest) (*v2.CommandRawResponse, error) { 46 | resp, err := s.rcon.Execute(ctx, req.Input) 47 | if err != nil { 48 | if errors.Is(err, rcon.ErrTimeout) { 49 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 50 | } 51 | 52 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 53 | } 54 | 55 | return &v2.CommandRawResponse{ 56 | Output: resp, 57 | }, nil 58 | } 59 | 60 | func (s *ConsoleService) CommandMessage(ctx context.Context, req *v2.CommandMessageRequest) (*v2.CommandMessageResponse, error) { 61 | if req.Message == "" { 62 | return nil, apierrors.NewErrInvalidArgument().WithDetail("message should not be empty").AsStatus() 63 | } 64 | 65 | resp, err := s.rcon.Execute(ctx, req.Message) 66 | if err != nil { 67 | if errors.Is(err, rcon.ErrTimeout) { 68 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 69 | } 70 | 71 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 72 | } 73 | 74 | s.logger.Info("executed command message and got response", zap.String("response", resp), zap.String("message", req.Message)) 75 | 76 | return &v2.CommandMessageResponse{}, nil 77 | } 78 | 79 | func (s *ConsoleService) CommandAlerts(ctx context.Context, req *v2.CommandAlertsRequest) (*v2.CommandAlertsResponse, error) { 80 | return nil, status.Errorf(codes.Unimplemented, "method CommandAlerts not implemented") 81 | } 82 | 83 | func (s *ConsoleService) CommandEnableResearchQueue(ctx context.Context, req *v2.CommandEnableResearchQueueRequest) (*v2.CommandEnableResearchQueueResponse, error) { 84 | return nil, status.Errorf(codes.Unimplemented, "method CommandEnableResearchQueue not implemented") 85 | } 86 | 87 | func (s *ConsoleService) CommandMuteProgrammableSpeakerForEveryone(ctx context.Context, req *v2.CommandMuteProgrammableSpeakerForEveryoneRequest) (*v2.CommandMuteProgrammableSpeakerForEveryoneResponse, error) { 88 | return nil, status.Errorf(codes.Unimplemented, "method CommandMuteProgrammableSpeakerForEveryone not implemented") 89 | } 90 | 91 | func (s *ConsoleService) CommandUnmuteProgrammableSpeakerForEveryone(ctx context.Context, req *v2.CommandUnmuteProgrammableSpeakerForEveryoneRequest) (*v2.CommandUnmuteProgrammableSpeakerForEveryoneResponse, error) { 92 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnmuteProgrammableSpeakerForEveryone not implemented") 93 | } 94 | 95 | func (s *ConsoleService) CommandPermissions(ctx context.Context, req *v2.CommandPermissionsRequest) (*v2.CommandPermissionsResponse, error) { 96 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissions not implemented") 97 | } 98 | 99 | func (s *ConsoleService) CommandPermissionsAddPlayer(ctx context.Context, req *v2.CommandPermissionsAddPlayerRequest) (*v2.CommandPermissionsAddPlayerResponse, error) { 100 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsAddPlayer not implemented") 101 | } 102 | 103 | func (s *ConsoleService) CommandPermissionsCreateGroup(ctx context.Context, req *v2.CommandPermissionsCreateGroupRequest) (*v2.CommandPermissionsCreateGroupResponse, error) { 104 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsCreateGroup not implemented") 105 | } 106 | 107 | func (s *ConsoleService) CommandPermissionsDeleteGroup(ctx context.Context, req *v2.CommandPermissionsDeleteGroupRequest) (*v2.CommandPermissionsDeleteGroupResponse, error) { 108 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsDeleteGroup not implemented") 109 | } 110 | 111 | func (s *ConsoleService) CommandPermissionsEditGroup(ctx context.Context, req *v2.CommandPermissionsEditGroupRequest) (*v2.CommandPermissionsEditGroupResponse, error) { 112 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsEditGroup not implemented") 113 | } 114 | 115 | func (s *ConsoleService) CommandPermissionsGetPlayerGroup(ctx context.Context, req *v2.CommandPermissionsGetPlayerGroupRequest) (*v2.CommandPermissionsGetPlayerGroupResponse, error) { 116 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsGetPlayerGroup not implemented") 117 | } 118 | 119 | func (s *ConsoleService) CommandPermissionsRemovePlayerGroup(ctx context.Context, req *v2.CommandPermissionsRemovePlayerGroupRequest) (*v2.CommandPermissionsRemovePlayerGroupResponse, error) { 120 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsRemovePlayerGroup not implemented") 121 | } 122 | 123 | func (s *ConsoleService) CommandPermissionsRenameGroup(ctx context.Context, req *v2.CommandPermissionsRenameGroupRequest) (*v2.CommandPermissionsRenameGroupResponse, error) { 124 | return nil, status.Errorf(codes.Unimplemented, "method CommandPermissionsRenameGroup not implemented") 125 | } 126 | 127 | func (s *ConsoleService) CommandResetTips(ctx context.Context, req *v2.CommandResetTipsRequest) (*v2.CommandResetTipsResponse, error) { 128 | return nil, status.Errorf(codes.Unimplemented, "method CommandResetTips not implemented") 129 | } 130 | 131 | var ( 132 | regexpEvolutionFactor = regexp.MustCompile(`(.*) - Evolution factor: ([0-9.]+)\. \(Time ([0-9.]+)%\) \(Pollution ([0-9.]+)%\) \(Spawner kills ([0-9.]+)%\)`) 133 | ) 134 | 135 | func parseEvolutionFactorLine(resp string) (*v2.Evolution, error) { 136 | normalized := strings.TrimSuffix(resp, "\n") 137 | 138 | // output: Nauvis - Evolution factor: 0.0000. (Time 0%) (Pollution 0%) (Spawner kills 0%) 139 | match := regexpEvolutionFactor.FindStringSubmatch(resp) 140 | if len(match) != 6 { 141 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse evolution factor: %s due to matches not equals 6", normalized).AsStatus() 142 | } 143 | 144 | // match[1] is the name 145 | planetName := match[1] 146 | 147 | // match[2] is the evolution factor 148 | evolutionFactor, err := strconv.ParseFloat(match[2], 64) 149 | if err != nil { 150 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse evolution factor: %s from %s due to %v", match[1], normalized, err).AsStatus() 151 | } 152 | 153 | // match[3] is the time 154 | time, err := strconv.ParseFloat(match[3], 64) 155 | if err != nil { 156 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse time: %s from %s due to %v", match[2], normalized, err).AsStatus() 157 | } 158 | 159 | // match[4] is the pollution 160 | pollution, err := strconv.ParseFloat(match[4], 64) 161 | if err != nil { 162 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse pollution: %s from %s due to %v", match[3], normalized, err).AsStatus() 163 | } 164 | 165 | // match[5] is the spawner kills 166 | spawnerKills, err := strconv.ParseFloat(match[5], 64) 167 | if err != nil { 168 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse spawner kills: %s from %s due to %v", match[4], normalized, err).AsStatus() 169 | } 170 | 171 | return &v2.Evolution{ 172 | SurfaceName: planetName, 173 | EvolutionFactor: evolutionFactor, 174 | Time: time, 175 | Pollution: pollution, 176 | SpawnerKills: spawnerKills, 177 | }, nil 178 | } 179 | 180 | func (s *ConsoleService) CommandEvolution(ctx context.Context, req *v2.CommandEvolutionRequest) (*v2.CommandEvolutionResponse, error) { 181 | resp, err := s.rcon.Execute(ctx, "/evolution") 182 | if err != nil { 183 | if errors.Is(err, rcon.ErrTimeout) { 184 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 185 | } 186 | 187 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 188 | } 189 | 190 | lines := strings.Split(resp, "\n") 191 | 192 | lines = lo.Map(lines, func(item string, _ int) string { 193 | return strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(item), "\r", ""), "\n", "") 194 | }) 195 | lines = lo.Filter(lines, func(item string, _ int) bool { 196 | return item != "" 197 | }) 198 | 199 | evolutions := make([]*v2.Evolution, 0, len(lines)) 200 | for _, line := range lines { 201 | evolution, err := parseEvolutionFactorLine(line) 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | evolutions = append(evolutions, evolution) 207 | } 208 | 209 | return &v2.CommandEvolutionResponse{ 210 | Evolutions: evolutions, 211 | }, nil 212 | } 213 | 214 | func (s *ConsoleService) CommandEvolutionGet(ctx context.Context, req *v2.CommandEvolutionGetRequest) (*v2.CommandEvolutionGetResponse, error) { 215 | resp, err := s.rcon.Execute(ctx, "/evolution "+req.SurfaceName) 216 | if err != nil { 217 | if errors.Is(err, rcon.ErrTimeout) { 218 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 219 | } 220 | 221 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 222 | } 223 | if strings.Contains(resp, "does not exist") { 224 | return nil, apierrors.NewErrNotFound().WithDetailf("surface %s does not exist", req.SurfaceName).AsStatus() 225 | } 226 | 227 | evolution, err := parseEvolutionFactorLine(resp) 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | return &v2.CommandEvolutionGetResponse{ 233 | Evolution: evolution, 234 | }, nil 235 | } 236 | 237 | func (s *ConsoleService) CommandSeed(ctx context.Context, req *v2.CommandSeedRequest) (*v2.CommandSeedResponse, error) { 238 | resp, err := s.rcon.Execute(ctx, "/seed") 239 | if err != nil { 240 | if errors.Is(err, rcon.ErrTimeout) { 241 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 242 | } 243 | 244 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 245 | } 246 | 247 | normalized := strings.TrimSuffix(resp, "\n") 248 | if normalized == "" { 249 | return &v2.CommandSeedResponse{}, nil 250 | } 251 | 252 | return &v2.CommandSeedResponse{ 253 | Seed: normalized, 254 | }, nil 255 | } 256 | 257 | func (s *ConsoleService) CommandTime(ctx context.Context, req *v2.CommandTimeRequest) (*v2.CommandTimeResponse, error) { 258 | resp, err := s.rcon.Execute(ctx, "/time") 259 | if err != nil { 260 | if errors.Is(err, rcon.ErrTimeout) { 261 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 262 | } 263 | 264 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 265 | } 266 | 267 | normalized := strings.TrimSuffix(resp, "\n") 268 | if normalized == "" { 269 | return &v2.CommandTimeResponse{}, nil 270 | } 271 | 272 | duration, err := utils.ParseDuration(normalized) 273 | if err != nil { 274 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 275 | } 276 | 277 | return &v2.CommandTimeResponse{ 278 | Time: duration.Seconds(), 279 | }, nil 280 | } 281 | 282 | func (s *ConsoleService) CommandToggleActionLogging(ctx context.Context, req *v2.CommandToggleActionLoggingRequest) (*v2.CommandToggleActionLoggingResponse, error) { 283 | return nil, status.Errorf(codes.Unimplemented, "method CommandToggleActionLogging not implemented") 284 | } 285 | 286 | func (s *ConsoleService) CommandToggleHeavyMode(ctx context.Context, req *v2.CommandToggleHeavyModeRequest) (*v2.CommandToggleHeavyModeResponse, error) { 287 | return nil, status.Errorf(codes.Unimplemented, "method CommandToggleHeavyMode not implemented") 288 | } 289 | 290 | func (s *ConsoleService) CommandUnlockShortcutBar(ctx context.Context, req *v2.CommandUnlockShortcutBarRequest) (*v2.CommandUnlockShortcutBarResponse, error) { 291 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnlockShortcutBar not implemented") 292 | } 293 | 294 | func (s *ConsoleService) CommandUnlockTips(ctx context.Context, req *v2.CommandUnlockTipsRequest) (*v2.CommandUnlockTipsResponse, error) { 295 | return nil, status.Errorf(codes.Unimplemented, "method CommandUnlockTips not implemented") 296 | } 297 | 298 | func (s *ConsoleService) CommandVersion(ctx context.Context, req *v2.CommandVersionRequest) (*v2.CommandVersionResponse, error) { 299 | resp, err := s.rcon.Execute(ctx, "/version") 300 | if err != nil { 301 | if errors.Is(err, rcon.ErrTimeout) { 302 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 303 | } 304 | 305 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 306 | } 307 | 308 | return &v2.CommandVersionResponse{ 309 | Version: strings.TrimSuffix(resp, "\n"), 310 | }, nil 311 | } 312 | 313 | func (s *ConsoleService) CommandAdmins(ctx context.Context, req *v2.CommandAdminsRequest) (*v2.CommandAdminsResponse, error) { 314 | resp, err := s.rcon.Execute(ctx, "/admins") 315 | if err != nil { 316 | if errors.Is(err, rcon.ErrTimeout) { 317 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 318 | } 319 | 320 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 321 | } 322 | 323 | admins, err := utils.StringListToPlayers(resp) 324 | if err != nil { 325 | return nil, err 326 | } 327 | 328 | return &v2.CommandAdminsResponse{ 329 | Admins: utils.MapV1PlayersToV2Players(admins), 330 | }, nil 331 | } 332 | 333 | func (s *ConsoleService) CommandBan(ctx context.Context, req *v2.CommandBanRequest) (*v2.CommandBanResponse, error) { 334 | if req.Username == "" { 335 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 336 | } 337 | 338 | cmd := "/ban " + req.Username 339 | 340 | resp, err := s.rcon.Execute(ctx, cmd) 341 | if err != nil { 342 | if errors.Is(err, rcon.ErrTimeout) { 343 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 344 | } 345 | 346 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 347 | } 348 | 349 | s.logger.Info("executed command ban and got response", zap.String("response", resp), zap.String("username", req.Username)) 350 | 351 | return &v2.CommandBanResponse{}, nil 352 | } 353 | 354 | func (s *ConsoleService) CommandBans(ctx context.Context, req *v2.CommandBansRequest) (*v2.CommandBansResponse, error) { 355 | resp, err := s.rcon.Execute(ctx, "/bans") 356 | if err != nil { 357 | if errors.Is(err, rcon.ErrTimeout) { 358 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 359 | } 360 | 361 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 362 | } 363 | 364 | // Cannot get banlist after ban via API · Issue #20 · nekomeowww/factorio-rcon-api 365 | // https://github.com/nekomeowww/factorio-rcon-api/issues/20 366 | // Example resp: 367 | // Banned players: user\n 368 | bans, err := utils.PrefixedStringCommaSeparatedListToPlayers(resp, "Banned players:") 369 | if err != nil { 370 | return nil, err 371 | } 372 | 373 | return &v2.CommandBansResponse{ 374 | Bans: utils.MapV1PlayersToV2Players(bans), 375 | }, nil 376 | } 377 | 378 | func (s *ConsoleService) CommandDemote(ctx context.Context, req *v2.CommandDemoteRequest) (*v2.CommandDemoteResponse, error) { 379 | if req.Username == "" { 380 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 381 | } 382 | 383 | resp, err := s.rcon.Execute(ctx, "/demote "+req.Username) 384 | if err != nil { 385 | if errors.Is(err, rcon.ErrTimeout) { 386 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 387 | } 388 | 389 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 390 | } 391 | 392 | s.logger.Info("executed command demote and got response", zap.String("response", resp), zap.String("username", req.Username)) 393 | 394 | return &v2.CommandDemoteResponse{}, nil 395 | } 396 | 397 | func (s *ConsoleService) CommandIgnore(ctx context.Context, req *v2.CommandIgnoreRequest) (*v2.CommandIgnoreResponse, error) { 398 | if req.Username == "" { 399 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 400 | } 401 | 402 | resp, err := s.rcon.Execute(ctx, "/ignore "+req.Username) 403 | if err != nil { 404 | if errors.Is(err, rcon.ErrTimeout) { 405 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 406 | } 407 | 408 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 409 | } 410 | 411 | s.logger.Info("executed command ignore and got response", zap.String("response", resp), zap.String("username", req.Username)) 412 | 413 | return &v2.CommandIgnoreResponse{}, nil 414 | } 415 | 416 | func (s *ConsoleService) CommandKick(ctx context.Context, req *v2.CommandKickRequest) (*v2.CommandKickResponse, error) { 417 | if req.Username == "" { 418 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 419 | } 420 | 421 | cmd := "/kick " + req.Username 422 | if req.Reason != "" { 423 | cmd += " " + req.Reason 424 | } 425 | 426 | resp, err := s.rcon.Execute(ctx, cmd) 427 | if err != nil { 428 | if errors.Is(err, rcon.ErrTimeout) { 429 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 430 | } 431 | 432 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 433 | } 434 | 435 | s.logger.Info("executed command kick and got response", zap.String("response", resp), zap.String("username", req.Username), zap.String("reason", req.Reason)) 436 | 437 | return &v2.CommandKickResponse{}, nil 438 | } 439 | 440 | func (s *ConsoleService) CommandMute(ctx context.Context, req *v2.CommandMuteRequest) (*v2.CommandMuteResponse, error) { 441 | if req.Username == "" { 442 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 443 | } 444 | 445 | resp, err := s.rcon.Execute(ctx, "/mute "+req.Username) 446 | if err != nil { 447 | if errors.Is(err, rcon.ErrTimeout) { 448 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 449 | } 450 | 451 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 452 | } 453 | 454 | s.logger.Info("executed command mute and got response", zap.String("response", resp), zap.String("username", req.Username)) 455 | 456 | return &v2.CommandMuteResponse{}, nil 457 | } 458 | 459 | func (s *ConsoleService) CommandMutes(ctx context.Context, req *v2.CommandMutesRequest) (*v2.CommandMutesResponse, error) { 460 | resp, err := s.rcon.Execute(ctx, "/mutes") 461 | if err != nil { 462 | if errors.Is(err, rcon.ErrTimeout) { 463 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 464 | } 465 | 466 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 467 | } 468 | 469 | mutes, err := utils.StringListToPlayers(resp) 470 | if err != nil { 471 | return nil, err 472 | } 473 | 474 | return &v2.CommandMutesResponse{ 475 | Mutes: utils.MapV1PlayersToV2Players(mutes), 476 | }, nil 477 | } 478 | 479 | func (s *ConsoleService) CommandPlayers(ctx context.Context, req *v2.CommandPlayersRequest) (*v2.CommandPlayersResponse, error) { 480 | resp, err := s.rcon.Execute(ctx, "/players") 481 | if err != nil { 482 | if errors.Is(err, rcon.ErrTimeout) { 483 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 484 | } 485 | 486 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 487 | } 488 | 489 | lines := strings.Split(resp, "\n") 490 | lines = lines[1 : len(lines)-1] 491 | 492 | players, err := utils.StringListToPlayers(strings.Join(lines, "\n")) 493 | if err != nil { 494 | return nil, err 495 | } 496 | 497 | return &v2.CommandPlayersResponse{ 498 | Players: utils.MapV1PlayersToV2Players(players), 499 | }, nil 500 | } 501 | 502 | func (s *ConsoleService) CommandPromote(ctx context.Context, req *v2.CommandPromoteRequest) (*v2.CommandPromoteResponse, error) { 503 | if req.Username == "" { 504 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 505 | } 506 | 507 | resp, err := s.rcon.Execute(ctx, "/promote "+req.Username) 508 | if err != nil { 509 | if errors.Is(err, rcon.ErrTimeout) { 510 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 511 | } 512 | 513 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 514 | } 515 | 516 | s.logger.Info("executed command promote and got response", zap.String("response", resp), zap.String("username", req.Username)) 517 | 518 | return &v2.CommandPromoteResponse{}, nil 519 | } 520 | 521 | func (s *ConsoleService) CommandPurge(ctx context.Context, req *v2.CommandPurgeRequest) (*v2.CommandPurgeResponse, error) { 522 | if req.Username == "" { 523 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 524 | } 525 | 526 | resp, err := s.rcon.Execute(ctx, "/purge"+" "+req.Username) 527 | if err != nil { 528 | if errors.Is(err, rcon.ErrTimeout) { 529 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 530 | } 531 | 532 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 533 | } 534 | 535 | s.logger.Info("executed command purge and got response", zap.String("response", resp), zap.String("username", req.Username)) 536 | 537 | return &v2.CommandPurgeResponse{}, nil 538 | } 539 | 540 | func (s *ConsoleService) CommandServerSave(ctx context.Context, req *v2.CommandServerSaveRequest) (*v2.CommandServerSaveResponse, error) { 541 | resp, err := s.rcon.Execute(ctx, "/server-save") 542 | if err != nil { 543 | if errors.Is(err, rcon.ErrTimeout) { 544 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 545 | } 546 | 547 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 548 | } 549 | 550 | s.logger.Info("executed command server-save and got response", zap.String("response", resp)) 551 | 552 | return &v2.CommandServerSaveResponse{}, nil 553 | } 554 | 555 | func (s *ConsoleService) CommandUnban(ctx context.Context, req *v2.CommandUnbanRequest) (*v2.CommandUnbanResponse, error) { 556 | if req.Username == "" { 557 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 558 | } 559 | 560 | resp, err := s.rcon.Execute(ctx, "/unban "+req.Username) 561 | if err != nil { 562 | if errors.Is(err, rcon.ErrTimeout) { 563 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 564 | } 565 | 566 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 567 | } 568 | 569 | s.logger.Info("executed command unban and got response", zap.String("response", resp), zap.String("username", req.Username)) 570 | 571 | return &v2.CommandUnbanResponse{}, nil 572 | } 573 | 574 | func (s *ConsoleService) CommandUnignore(ctx context.Context, req *v2.CommandUnignoreRequest) (*v2.CommandUnignoreResponse, error) { 575 | if req.Username == "" { 576 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 577 | } 578 | 579 | resp, err := s.rcon.Execute(ctx, "/unignore "+req.Username) 580 | if err != nil { 581 | if errors.Is(err, rcon.ErrTimeout) { 582 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 583 | } 584 | 585 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 586 | } 587 | 588 | s.logger.Info("executed command unignore and got response", zap.String("response", resp), zap.String("username", req.Username)) 589 | 590 | return &v2.CommandUnignoreResponse{}, nil 591 | } 592 | 593 | func (s *ConsoleService) CommandUnmute(ctx context.Context, req *v2.CommandUnmuteRequest) (*v2.CommandUnmuteResponse, error) { 594 | if req.Username == "" { 595 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 596 | } 597 | 598 | resp, err := s.rcon.Execute(ctx, "/unmute "+req.Username) 599 | if err != nil { 600 | if errors.Is(err, rcon.ErrTimeout) { 601 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 602 | } 603 | 604 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 605 | } 606 | 607 | s.logger.Info("executed command unmute and got response", zap.String("response", resp), zap.String("username", req.Username)) 608 | 609 | return &v2.CommandUnmuteResponse{}, nil 610 | } 611 | 612 | func (s *ConsoleService) CommandWhisper(ctx context.Context, req *v2.CommandWhisperRequest) (*v2.CommandWhisperResponse, error) { 613 | if req.Username == "" { 614 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 615 | } 616 | if req.Message == "" { 617 | return nil, apierrors.NewErrInvalidArgument().WithDetail("message should not be empty").AsStatus() 618 | } 619 | 620 | resp, err := s.rcon.Execute(ctx, "/whisper "+req.Username+" "+req.Message) 621 | if err != nil { 622 | if errors.Is(err, rcon.ErrTimeout) { 623 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 624 | } 625 | 626 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 627 | } 628 | 629 | s.logger.Info("executed command whisper and got response", zap.String("response", resp), zap.String("username", req.Username), zap.String("message", req.Message)) 630 | 631 | return &v2.CommandWhisperResponse{}, nil 632 | } 633 | 634 | func (s *ConsoleService) CommandWhitelistAdd(ctx context.Context, req *v2.CommandWhitelistAddRequest) (*v2.CommandWhitelistAddResponse, error) { 635 | if req.Username == "" { 636 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 637 | } 638 | 639 | resp, err := s.rcon.Execute(ctx, "/whitelist add "+req.Username) 640 | if err != nil { 641 | if errors.Is(err, rcon.ErrTimeout) { 642 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 643 | } 644 | 645 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 646 | } 647 | 648 | s.logger.Info("executed command whitelist add and got response", zap.String("response", resp), zap.String("username", req.Username)) 649 | 650 | return &v2.CommandWhitelistAddResponse{}, nil 651 | } 652 | 653 | func (s *ConsoleService) CommandWhitelistGet(ctx context.Context, req *v2.CommandWhitelistGetRequest) (*v2.CommandWhitelistGetResponse, error) { 654 | resp, err := s.rcon.Execute(ctx, "/whitelist get") 655 | if err != nil { 656 | if errors.Is(err, rcon.ErrTimeout) { 657 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 658 | } 659 | 660 | return nil, apierrors.NewErrInternal().WithDetail(err.Error()).WithError(err).WithCaller().AsStatus() 661 | } 662 | 663 | resp = strings.TrimPrefix(resp, "Whitelisted players:") 664 | resp = strings.TrimSpace(resp) 665 | 666 | playerNames := utils.ParseWhitelistedPlayers(resp) 667 | 668 | players := lo.Map(playerNames, func(player string, _ int) *v2.Player { 669 | return &v2.Player{ 670 | Username: player, 671 | } 672 | }) 673 | 674 | savePlayers, err := s.CommandPlayers(ctx, &v2.CommandPlayersRequest{}) 675 | if err != nil { 676 | return nil, err 677 | } 678 | 679 | mPlayers := lo.SliceToMap(savePlayers.Players, func(player *v2.Player) (string, *v2.Player) { 680 | return player.Username, player 681 | }) 682 | 683 | for _, player := range players { 684 | if p, ok := mPlayers[player.Username]; ok { 685 | player.Online = p.Online 686 | } 687 | } 688 | 689 | return &v2.CommandWhitelistGetResponse{ 690 | Whitelist: players, 691 | }, nil 692 | } 693 | 694 | func (s *ConsoleService) CommandWhitelistRemove(ctx context.Context, req *v2.CommandWhitelistRemoveRequest) (*v2.CommandWhitelistRemoveResponse, error) { 695 | if req.Username == "" { 696 | return nil, apierrors.NewErrInvalidArgument().WithDetail("username should not be empty").AsStatus() 697 | } 698 | 699 | resp, err := s.rcon.Execute(ctx, "/whitelist remove "+req.Username) 700 | if err != nil { 701 | if errors.Is(err, rcon.ErrTimeout) { 702 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 703 | } 704 | 705 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 706 | } 707 | 708 | s.logger.Info("executed command whitelist remove and got response", zap.String("response", resp), zap.String("username", req.Username)) 709 | 710 | return &v2.CommandWhitelistRemoveResponse{}, nil 711 | } 712 | 713 | func (s *ConsoleService) CommandWhitelistClear(ctx context.Context, req *v2.CommandWhitelistClearRequest) (*v2.CommandWhitelistClearResponse, error) { 714 | resp, err := s.rcon.Execute(ctx, "/whitelist clear") 715 | if err != nil { 716 | if errors.Is(err, rcon.ErrTimeout) { 717 | return nil, apierrors.NewErrTimeout().WithDetail("RCON connection is not established within deadline threshold").AsStatus() 718 | } 719 | 720 | return nil, apierrors.NewErrBadRequest().WithDetail(err.Error()).AsStatus() 721 | } 722 | 723 | s.logger.Info("executed command whitelist clear and got response", zap.String("response", resp)) 724 | 725 | return &v2.CommandWhitelistClearResponse{}, nil 726 | } 727 | -------------------------------------------------------------------------------- /internal/grpc/services/factorioapi/v2/console/console_test.go: -------------------------------------------------------------------------------- 1 | package consolev2 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | v2 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v2" 8 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/libs" 9 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon/fake" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | func TestCommandEvolution(t *testing.T) { 16 | t.Parallel() 17 | 18 | logger, err := libs.NewLogger()() 19 | require.NoError(t, err) 20 | require.NotNil(t, logger) 21 | 22 | rcon := new(fake.FakeRCON) 23 | 24 | c := NewConsoleService()(NewConsoleServiceParams{ 25 | Logger: logger, 26 | RCON: rcon, 27 | }) 28 | 29 | rcon.ExecuteReturns("Nauvis - Evolution factor: 0.9495. (Time 10%) (Pollution 84%) (Spawner kills 5%)\nbpsb-lab-p-Kynazori - Evolution factor: 0.6358. (Time 100%) (Pollution 0%) (Spawner kills 0%)\nbpsb-lab-f-player - Evolution factor: 0.6672. (Time 87%) (Pollution 13%) (Spawner kills 0%)\nbpsb-lab-p-Kunstduenger - Evolution factor: 0.6355. (Time 100%) (Pollution 0%) (Spawner kills 0%)\nbpsb-lab-p-Daemon16 - Evolution factor: 0.6036. (Time 100%) (Pollution 0%) (Spawner kills 0%)\nVulcanus - Evolution factor: 0.5852. (Time 100%) (Pollution 0%) (Spawner kills 0%)\nGleba - Evolution factor: 0.6436. (Time 65%) (Pollution 16%) (Spawner kills 19%)\nFulgora - Evolution factor: 0.5347. (Time 100%) (Pollution 0%) (Spawner kills 0%)\n\n", nil) 30 | 31 | resp, err := c.CommandEvolution(context.Background(), &v2.CommandEvolutionRequest{}) 32 | require.NoError(t, err) 33 | require.NotNil(t, resp) 34 | 35 | require.Len(t, resp.Evolutions, 8) 36 | 37 | assert.Equal(t, []*v2.Evolution{ 38 | { 39 | SurfaceName: "Nauvis", 40 | EvolutionFactor: 0.9495, 41 | Time: 10, 42 | Pollution: 84, 43 | SpawnerKills: 5, 44 | }, 45 | { 46 | SurfaceName: "bpsb-lab-p-Kynazori", 47 | EvolutionFactor: 0.6358, 48 | Time: 100, 49 | Pollution: 0, 50 | SpawnerKills: 0, 51 | }, 52 | { 53 | SurfaceName: "bpsb-lab-f-player", 54 | EvolutionFactor: 0.6672, 55 | Time: 87, 56 | Pollution: 13, 57 | SpawnerKills: 0, 58 | }, 59 | { 60 | SurfaceName: "bpsb-lab-p-Kunstduenger", 61 | EvolutionFactor: 0.6355, 62 | Time: 100, 63 | Pollution: 0, 64 | SpawnerKills: 0, 65 | }, 66 | { 67 | SurfaceName: "bpsb-lab-p-Daemon16", 68 | EvolutionFactor: 0.6036, 69 | Time: 100, 70 | Pollution: 0, 71 | SpawnerKills: 0, 72 | }, 73 | { 74 | SurfaceName: "Vulcanus", 75 | EvolutionFactor: 0.5852, 76 | Time: 100, 77 | Pollution: 0, 78 | SpawnerKills: 0, 79 | }, 80 | { 81 | SurfaceName: "Gleba", 82 | EvolutionFactor: 0.6436, 83 | Time: 65, 84 | Pollution: 16, 85 | SpawnerKills: 19, 86 | }, 87 | { 88 | SurfaceName: "Fulgora", 89 | EvolutionFactor: 0.5347, 90 | Time: 100, 91 | Pollution: 0, 92 | SpawnerKills: 0, 93 | }, 94 | }, resp.Evolutions) 95 | 96 | } 97 | 98 | func TestCommandEvolutionGet(t *testing.T) { 99 | t.Run("Default", func(t *testing.T) { 100 | t.Parallel() 101 | 102 | logger, err := libs.NewLogger()() 103 | require.NoError(t, err) 104 | require.NotNil(t, logger) 105 | 106 | rcon := new(fake.FakeRCON) 107 | 108 | c := NewConsoleService()(NewConsoleServiceParams{ 109 | Logger: logger, 110 | RCON: rcon, 111 | }) 112 | 113 | rcon.ExecuteReturns("\nNauvis - Evolution factor: 0.9495. (Time 10%) (Pollution 84%) (Spawner kills 5%)\n\n", nil) 114 | 115 | resp, err := c.CommandEvolutionGet(context.Background(), &v2.CommandEvolutionGetRequest{ 116 | SurfaceName: "Nauvis", 117 | }) 118 | require.NoError(t, err) 119 | require.NotNil(t, resp) 120 | 121 | assert.Equal(t, &v2.Evolution{ 122 | SurfaceName: "Nauvis", 123 | EvolutionFactor: 0.9495, 124 | Time: 10, 125 | Pollution: 84, 126 | SpawnerKills: 5, 127 | }, resp.Evolution) 128 | }) 129 | 130 | t.Run("NotExists", func(t *testing.T) { 131 | t.Parallel() 132 | 133 | logger, err := libs.NewLogger()() 134 | require.NoError(t, err) 135 | require.NotNil(t, logger) 136 | 137 | rcon := new(fake.FakeRCON) 138 | 139 | c := NewConsoleService()(NewConsoleServiceParams{ 140 | Logger: logger, 141 | RCON: rcon, 142 | }) 143 | 144 | rcon.ExecuteReturns("Surface \"Nauvis\" does not exist.\n", nil) 145 | 146 | resp, err := c.CommandEvolutionGet(context.Background(), &v2.CommandEvolutionGetRequest{ 147 | SurfaceName: "Nauvis", 148 | }) 149 | require.Error(t, err) 150 | require.Nil(t, resp) 151 | 152 | statusErr, ok := status.FromError(err) 153 | require.True(t, ok) 154 | require.NotNil(t, statusErr) 155 | 156 | assert.Equal(t, statusErr.Message(), "surface Nauvis does not exist") 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /internal/grpc/services/services.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | "google.golang.org/grpc/reflection" 6 | 7 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/grpc/services/factorioapi" 8 | grpcpkg "github.com/nekomeowww/factorio-rcon-api/v2/pkg/grpc" 9 | ) 10 | 11 | func Modules() fx.Option { 12 | return fx.Options( 13 | fx.Provide(NewRegister()), 14 | fx.Options(factorioapi.Modules()), 15 | ) 16 | } 17 | 18 | type NewRegisterParams struct { 19 | fx.In 20 | 21 | FactorioAPI *factorioapi.FactorioAPI 22 | } 23 | 24 | func NewRegister() func(params NewRegisterParams) *grpcpkg.Register { 25 | return func(params NewRegisterParams) *grpcpkg.Register { 26 | register := grpcpkg.NewRegister() 27 | 28 | params.FactorioAPI.Register(register) 29 | 30 | register.RegisterGrpcService(func(s reflection.GRPCServer) { 31 | reflection.Register(s) 32 | }) 33 | 34 | return register 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/libs/libs.go: -------------------------------------------------------------------------------- 1 | package libs 2 | 3 | import "go.uber.org/fx" 4 | 5 | func Modules() fx.Option { 6 | return fx.Options( 7 | fx.Provide(NewLogger()), 8 | fx.Provide(NewOtel()), 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /internal/libs/logger.go: -------------------------------------------------------------------------------- 1 | package libs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/nekomeowww/xo" 10 | "github.com/nekomeowww/xo/logger" 11 | "github.com/nekomeowww/xo/logger/loki" 12 | "github.com/samber/lo" 13 | "go.uber.org/zap/zapcore" 14 | ) 15 | 16 | func NewLogger() func() (*logger.Logger, error) { 17 | return func() (*logger.Logger, error) { 18 | logLevel, err := logger.ReadLogLevelFromEnv() 19 | if err != nil { 20 | logLevel = zapcore.InfoLevel 21 | } 22 | 23 | var isFatalLevel bool 24 | if logLevel == zapcore.FatalLevel { 25 | isFatalLevel = true 26 | logLevel = zapcore.InfoLevel 27 | } 28 | 29 | logFormat, readFormatError := logger.ReadLogFormatFromEnv() 30 | 31 | logger, err := logger.NewLogger( 32 | logger.WithLevel(logLevel), 33 | logger.WithAppName("factorio-rcon-api"), 34 | logger.WithNamespace("nekomeowww"), 35 | logger.WithLogFilePath(xo.RelativePathBasedOnPwdOf(filepath.Join("logs", "logs.log"))), 36 | logger.WithFormat(logFormat), 37 | logger.WithLokiRemoteConfig(lo.Ternary(os.Getenv("LOG_LOKI_REMOTE_URL") != "", &loki.Config{ 38 | Url: os.Getenv("LOG_LOKI_REMOTE_URL"), 39 | BatchMaxSize: 2000, //nolint:mnd 40 | BatchMaxWait: 10 * time.Second, //nolint:mnd 41 | PrintErrors: true, 42 | Labels: map[string]string{}, 43 | }, nil)), 44 | ) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to create logger: %w", err) 47 | } 48 | if isFatalLevel { 49 | logger.Error("fatal log level is unacceptable, fallbacks to info level") 50 | } 51 | if readFormatError != nil { 52 | logger.Error("failed to read log format from env, fallbacks to json") 53 | } 54 | 55 | logger = logger.WithAndSkip( 56 | 1, 57 | ) 58 | 59 | return logger, nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/libs/tracing.go: -------------------------------------------------------------------------------- 1 | package libs 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "time" 8 | 9 | "go.opentelemetry.io/contrib/instrumentation/runtime" 10 | "go.opentelemetry.io/otel" 11 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" 12 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace" 13 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" 14 | "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" 15 | "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" 16 | "go.opentelemetry.io/otel/propagation" 17 | "go.opentelemetry.io/otel/sdk/metric" 18 | "go.opentelemetry.io/otel/sdk/trace" 19 | "go.uber.org/fx" 20 | "go.uber.org/zap" 21 | 22 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/configs" 23 | "github.com/nekomeowww/xo/logger" 24 | ) 25 | 26 | type NewOtelParams struct { 27 | fx.In 28 | 29 | Lifecycle fx.Lifecycle 30 | Config *configs.Config 31 | Logger *logger.Logger 32 | } 33 | 34 | type Otel struct { 35 | Trace *trace.TracerProvider 36 | Metric *metric.MeterProvider 37 | } 38 | 39 | func NewOtel() func(params NewOtelParams) (*Otel, error) { 40 | return func(params NewOtelParams) (*Otel, error) { 41 | prop := propagation.NewCompositeTextMapPropagator( 42 | propagation.TraceContext{}, 43 | propagation.Baggage{}, 44 | ) 45 | 46 | otel.SetTextMapPropagator(prop) 47 | 48 | o := &Otel{} 49 | 50 | params.Lifecycle.Append(fx.Hook{ 51 | OnStart: func(ctx context.Context) error { 52 | { 53 | var err error 54 | var spanExporter trace.SpanExporter 55 | 56 | if params.Config.Tracing.OtelCollectorHTTP { 57 | params.Logger.Info("configured to use otlp collector for tracing", zap.String("protocol", "http/protobuf"), zap.String("endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))) 58 | 59 | spanExporter, err = otlptrace.New(ctx, otlptracehttp.NewClient()) 60 | if err != nil { 61 | return err 62 | } 63 | } else if params.Config.Tracing.OtelStdoutEnabled { 64 | params.Logger.Info("configured to use stdout exporter for tracing") 65 | 66 | spanExporter, err = stdouttrace.New(stdouttrace.WithPrettyPrint()) 67 | if err != nil { 68 | return err 69 | } 70 | } else { 71 | params.Logger.Info("configured to disable stdout exporter for tracing") 72 | 73 | spanExporter, err = stdouttrace.New(stdouttrace.WithWriter(io.Discard)) 74 | if err != nil { 75 | return err 76 | } 77 | } 78 | 79 | tracerProvider := trace.NewTracerProvider(trace.WithBatcher(spanExporter)) 80 | otel.SetTracerProvider(tracerProvider) 81 | 82 | o.Trace = tracerProvider 83 | 84 | params.Lifecycle.Append(fx.Hook{ 85 | OnStop: func(ctx context.Context) error { 86 | return tracerProvider.Shutdown(ctx) 87 | }, 88 | }) 89 | } 90 | 91 | { 92 | var err error 93 | var metricExporter metric.Exporter 94 | 95 | if params.Config.Tracing.OtelCollectorHTTP { 96 | params.Logger.Info("configured to use otlp collector for metric", zap.String("protocol", "http/protobuf"), zap.String("endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))) 97 | 98 | metricExporter, err = otlpmetrichttp.New(ctx) 99 | if err != nil { 100 | return err 101 | } 102 | } else if params.Config.Tracing.OtelStdoutEnabled { 103 | params.Logger.Info("configured to use stdout exporter for metric") 104 | 105 | metricExporter, err = stdoutmetric.New(stdoutmetric.WithPrettyPrint()) 106 | if err != nil { 107 | return err 108 | } 109 | } else { 110 | params.Logger.Info("configured to disable stdout exporter for metric") 111 | 112 | metricExporter, err = stdoutmetric.New(stdoutmetric.WithWriter(io.Discard)) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | 118 | meterProvider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(metricExporter))) 119 | otel.SetMeterProvider(meterProvider) 120 | o.Metric = meterProvider 121 | 122 | params.Lifecycle.Append(fx.Hook{ 123 | OnStop: func(ctx context.Context) error { 124 | return meterProvider.Shutdown(ctx) 125 | }, 126 | }) 127 | } 128 | 129 | err := runtime.Start(runtime.WithMinimumReadMemStatsInterval(time.Second)) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return nil 135 | }, 136 | }) 137 | 138 | return o, nil 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /internal/meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | var ( 4 | Version = "1.0.0" 5 | LastCommit = "abcdefgh" 6 | Env = "dev" 7 | ) 8 | 9 | type Meta struct { 10 | Namespace string `json:"namespace" yaml:"namespace"` 11 | App string `json:"app" yaml:"app"` 12 | Version string `json:"version" yaml:"version"` 13 | LastCommit string `json:"last_commit" yaml:"last_commit"` 14 | Env string `json:"env" yaml:"env"` 15 | } 16 | 17 | func NewMeta(namespace, app string) *Meta { 18 | return &Meta{ 19 | Namespace: namespace, 20 | App: app, 21 | Version: Version, 22 | LastCommit: LastCommit, 23 | Env: Env, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/rcon/fake/rcon.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package fake 3 | 4 | import ( 5 | "context" 6 | "net" 7 | "sync" 8 | 9 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/rcon" 10 | ) 11 | 12 | type FakeRCON struct { 13 | CloseStub func() error 14 | closeMutex sync.RWMutex 15 | closeArgsForCall []struct { 16 | } 17 | closeReturns struct { 18 | result1 error 19 | } 20 | closeReturnsOnCall map[int]struct { 21 | result1 error 22 | } 23 | ExecuteStub func(context.Context, string) (string, error) 24 | executeMutex sync.RWMutex 25 | executeArgsForCall []struct { 26 | arg1 context.Context 27 | arg2 string 28 | } 29 | executeReturns struct { 30 | result1 string 31 | result2 error 32 | } 33 | executeReturnsOnCall map[int]struct { 34 | result1 string 35 | result2 error 36 | } 37 | IsReadyStub func() bool 38 | isReadyMutex sync.RWMutex 39 | isReadyArgsForCall []struct { 40 | } 41 | isReadyReturns struct { 42 | result1 bool 43 | } 44 | isReadyReturnsOnCall map[int]struct { 45 | result1 bool 46 | } 47 | LocalAddrStub func() net.Addr 48 | localAddrMutex sync.RWMutex 49 | localAddrArgsForCall []struct { 50 | } 51 | localAddrReturns struct { 52 | result1 net.Addr 53 | } 54 | localAddrReturnsOnCall map[int]struct { 55 | result1 net.Addr 56 | } 57 | RemoteAddrStub func() net.Addr 58 | remoteAddrMutex sync.RWMutex 59 | remoteAddrArgsForCall []struct { 60 | } 61 | remoteAddrReturns struct { 62 | result1 net.Addr 63 | } 64 | remoteAddrReturnsOnCall map[int]struct { 65 | result1 net.Addr 66 | } 67 | invocations map[string][][]interface{} 68 | invocationsMutex sync.RWMutex 69 | } 70 | 71 | func (fake *FakeRCON) Close() error { 72 | fake.closeMutex.Lock() 73 | ret, specificReturn := fake.closeReturnsOnCall[len(fake.closeArgsForCall)] 74 | fake.closeArgsForCall = append(fake.closeArgsForCall, struct { 75 | }{}) 76 | stub := fake.CloseStub 77 | fakeReturns := fake.closeReturns 78 | fake.recordInvocation("Close", []interface{}{}) 79 | fake.closeMutex.Unlock() 80 | if stub != nil { 81 | return stub() 82 | } 83 | if specificReturn { 84 | return ret.result1 85 | } 86 | return fakeReturns.result1 87 | } 88 | 89 | func (fake *FakeRCON) CloseCallCount() int { 90 | fake.closeMutex.RLock() 91 | defer fake.closeMutex.RUnlock() 92 | return len(fake.closeArgsForCall) 93 | } 94 | 95 | func (fake *FakeRCON) CloseCalls(stub func() error) { 96 | fake.closeMutex.Lock() 97 | defer fake.closeMutex.Unlock() 98 | fake.CloseStub = stub 99 | } 100 | 101 | func (fake *FakeRCON) CloseReturns(result1 error) { 102 | fake.closeMutex.Lock() 103 | defer fake.closeMutex.Unlock() 104 | fake.CloseStub = nil 105 | fake.closeReturns = struct { 106 | result1 error 107 | }{result1} 108 | } 109 | 110 | func (fake *FakeRCON) CloseReturnsOnCall(i int, result1 error) { 111 | fake.closeMutex.Lock() 112 | defer fake.closeMutex.Unlock() 113 | fake.CloseStub = nil 114 | if fake.closeReturnsOnCall == nil { 115 | fake.closeReturnsOnCall = make(map[int]struct { 116 | result1 error 117 | }) 118 | } 119 | fake.closeReturnsOnCall[i] = struct { 120 | result1 error 121 | }{result1} 122 | } 123 | 124 | func (fake *FakeRCON) Execute(arg1 context.Context, arg2 string) (string, error) { 125 | fake.executeMutex.Lock() 126 | ret, specificReturn := fake.executeReturnsOnCall[len(fake.executeArgsForCall)] 127 | fake.executeArgsForCall = append(fake.executeArgsForCall, struct { 128 | arg1 context.Context 129 | arg2 string 130 | }{arg1, arg2}) 131 | stub := fake.ExecuteStub 132 | fakeReturns := fake.executeReturns 133 | fake.recordInvocation("Execute", []interface{}{arg1, arg2}) 134 | fake.executeMutex.Unlock() 135 | if stub != nil { 136 | return stub(arg1, arg2) 137 | } 138 | if specificReturn { 139 | return ret.result1, ret.result2 140 | } 141 | return fakeReturns.result1, fakeReturns.result2 142 | } 143 | 144 | func (fake *FakeRCON) ExecuteCallCount() int { 145 | fake.executeMutex.RLock() 146 | defer fake.executeMutex.RUnlock() 147 | return len(fake.executeArgsForCall) 148 | } 149 | 150 | func (fake *FakeRCON) ExecuteCalls(stub func(context.Context, string) (string, error)) { 151 | fake.executeMutex.Lock() 152 | defer fake.executeMutex.Unlock() 153 | fake.ExecuteStub = stub 154 | } 155 | 156 | func (fake *FakeRCON) ExecuteArgsForCall(i int) (context.Context, string) { 157 | fake.executeMutex.RLock() 158 | defer fake.executeMutex.RUnlock() 159 | argsForCall := fake.executeArgsForCall[i] 160 | return argsForCall.arg1, argsForCall.arg2 161 | } 162 | 163 | func (fake *FakeRCON) ExecuteReturns(result1 string, result2 error) { 164 | fake.executeMutex.Lock() 165 | defer fake.executeMutex.Unlock() 166 | fake.ExecuteStub = nil 167 | fake.executeReturns = struct { 168 | result1 string 169 | result2 error 170 | }{result1, result2} 171 | } 172 | 173 | func (fake *FakeRCON) ExecuteReturnsOnCall(i int, result1 string, result2 error) { 174 | fake.executeMutex.Lock() 175 | defer fake.executeMutex.Unlock() 176 | fake.ExecuteStub = nil 177 | if fake.executeReturnsOnCall == nil { 178 | fake.executeReturnsOnCall = make(map[int]struct { 179 | result1 string 180 | result2 error 181 | }) 182 | } 183 | fake.executeReturnsOnCall[i] = struct { 184 | result1 string 185 | result2 error 186 | }{result1, result2} 187 | } 188 | 189 | func (fake *FakeRCON) IsReady() bool { 190 | fake.isReadyMutex.Lock() 191 | ret, specificReturn := fake.isReadyReturnsOnCall[len(fake.isReadyArgsForCall)] 192 | fake.isReadyArgsForCall = append(fake.isReadyArgsForCall, struct { 193 | }{}) 194 | stub := fake.IsReadyStub 195 | fakeReturns := fake.isReadyReturns 196 | fake.recordInvocation("IsReady", []interface{}{}) 197 | fake.isReadyMutex.Unlock() 198 | if stub != nil { 199 | return stub() 200 | } 201 | if specificReturn { 202 | return ret.result1 203 | } 204 | return fakeReturns.result1 205 | } 206 | 207 | func (fake *FakeRCON) IsReadyCallCount() int { 208 | fake.isReadyMutex.RLock() 209 | defer fake.isReadyMutex.RUnlock() 210 | return len(fake.isReadyArgsForCall) 211 | } 212 | 213 | func (fake *FakeRCON) IsReadyCalls(stub func() bool) { 214 | fake.isReadyMutex.Lock() 215 | defer fake.isReadyMutex.Unlock() 216 | fake.IsReadyStub = stub 217 | } 218 | 219 | func (fake *FakeRCON) IsReadyReturns(result1 bool) { 220 | fake.isReadyMutex.Lock() 221 | defer fake.isReadyMutex.Unlock() 222 | fake.IsReadyStub = nil 223 | fake.isReadyReturns = struct { 224 | result1 bool 225 | }{result1} 226 | } 227 | 228 | func (fake *FakeRCON) IsReadyReturnsOnCall(i int, result1 bool) { 229 | fake.isReadyMutex.Lock() 230 | defer fake.isReadyMutex.Unlock() 231 | fake.IsReadyStub = nil 232 | if fake.isReadyReturnsOnCall == nil { 233 | fake.isReadyReturnsOnCall = make(map[int]struct { 234 | result1 bool 235 | }) 236 | } 237 | fake.isReadyReturnsOnCall[i] = struct { 238 | result1 bool 239 | }{result1} 240 | } 241 | 242 | func (fake *FakeRCON) LocalAddr() net.Addr { 243 | fake.localAddrMutex.Lock() 244 | ret, specificReturn := fake.localAddrReturnsOnCall[len(fake.localAddrArgsForCall)] 245 | fake.localAddrArgsForCall = append(fake.localAddrArgsForCall, struct { 246 | }{}) 247 | stub := fake.LocalAddrStub 248 | fakeReturns := fake.localAddrReturns 249 | fake.recordInvocation("LocalAddr", []interface{}{}) 250 | fake.localAddrMutex.Unlock() 251 | if stub != nil { 252 | return stub() 253 | } 254 | if specificReturn { 255 | return ret.result1 256 | } 257 | return fakeReturns.result1 258 | } 259 | 260 | func (fake *FakeRCON) LocalAddrCallCount() int { 261 | fake.localAddrMutex.RLock() 262 | defer fake.localAddrMutex.RUnlock() 263 | return len(fake.localAddrArgsForCall) 264 | } 265 | 266 | func (fake *FakeRCON) LocalAddrCalls(stub func() net.Addr) { 267 | fake.localAddrMutex.Lock() 268 | defer fake.localAddrMutex.Unlock() 269 | fake.LocalAddrStub = stub 270 | } 271 | 272 | func (fake *FakeRCON) LocalAddrReturns(result1 net.Addr) { 273 | fake.localAddrMutex.Lock() 274 | defer fake.localAddrMutex.Unlock() 275 | fake.LocalAddrStub = nil 276 | fake.localAddrReturns = struct { 277 | result1 net.Addr 278 | }{result1} 279 | } 280 | 281 | func (fake *FakeRCON) LocalAddrReturnsOnCall(i int, result1 net.Addr) { 282 | fake.localAddrMutex.Lock() 283 | defer fake.localAddrMutex.Unlock() 284 | fake.LocalAddrStub = nil 285 | if fake.localAddrReturnsOnCall == nil { 286 | fake.localAddrReturnsOnCall = make(map[int]struct { 287 | result1 net.Addr 288 | }) 289 | } 290 | fake.localAddrReturnsOnCall[i] = struct { 291 | result1 net.Addr 292 | }{result1} 293 | } 294 | 295 | func (fake *FakeRCON) RemoteAddr() net.Addr { 296 | fake.remoteAddrMutex.Lock() 297 | ret, specificReturn := fake.remoteAddrReturnsOnCall[len(fake.remoteAddrArgsForCall)] 298 | fake.remoteAddrArgsForCall = append(fake.remoteAddrArgsForCall, struct { 299 | }{}) 300 | stub := fake.RemoteAddrStub 301 | fakeReturns := fake.remoteAddrReturns 302 | fake.recordInvocation("RemoteAddr", []interface{}{}) 303 | fake.remoteAddrMutex.Unlock() 304 | if stub != nil { 305 | return stub() 306 | } 307 | if specificReturn { 308 | return ret.result1 309 | } 310 | return fakeReturns.result1 311 | } 312 | 313 | func (fake *FakeRCON) RemoteAddrCallCount() int { 314 | fake.remoteAddrMutex.RLock() 315 | defer fake.remoteAddrMutex.RUnlock() 316 | return len(fake.remoteAddrArgsForCall) 317 | } 318 | 319 | func (fake *FakeRCON) RemoteAddrCalls(stub func() net.Addr) { 320 | fake.remoteAddrMutex.Lock() 321 | defer fake.remoteAddrMutex.Unlock() 322 | fake.RemoteAddrStub = stub 323 | } 324 | 325 | func (fake *FakeRCON) RemoteAddrReturns(result1 net.Addr) { 326 | fake.remoteAddrMutex.Lock() 327 | defer fake.remoteAddrMutex.Unlock() 328 | fake.RemoteAddrStub = nil 329 | fake.remoteAddrReturns = struct { 330 | result1 net.Addr 331 | }{result1} 332 | } 333 | 334 | func (fake *FakeRCON) RemoteAddrReturnsOnCall(i int, result1 net.Addr) { 335 | fake.remoteAddrMutex.Lock() 336 | defer fake.remoteAddrMutex.Unlock() 337 | fake.RemoteAddrStub = nil 338 | if fake.remoteAddrReturnsOnCall == nil { 339 | fake.remoteAddrReturnsOnCall = make(map[int]struct { 340 | result1 net.Addr 341 | }) 342 | } 343 | fake.remoteAddrReturnsOnCall[i] = struct { 344 | result1 net.Addr 345 | }{result1} 346 | } 347 | 348 | func (fake *FakeRCON) Invocations() map[string][][]interface{} { 349 | fake.invocationsMutex.RLock() 350 | defer fake.invocationsMutex.RUnlock() 351 | fake.closeMutex.RLock() 352 | defer fake.closeMutex.RUnlock() 353 | fake.executeMutex.RLock() 354 | defer fake.executeMutex.RUnlock() 355 | fake.isReadyMutex.RLock() 356 | defer fake.isReadyMutex.RUnlock() 357 | fake.localAddrMutex.RLock() 358 | defer fake.localAddrMutex.RUnlock() 359 | fake.remoteAddrMutex.RLock() 360 | defer fake.remoteAddrMutex.RUnlock() 361 | copiedInvocations := map[string][][]interface{}{} 362 | for key, value := range fake.invocations { 363 | copiedInvocations[key] = value 364 | } 365 | return copiedInvocations 366 | } 367 | 368 | func (fake *FakeRCON) recordInvocation(key string, args []interface{}) { 369 | fake.invocationsMutex.Lock() 370 | defer fake.invocationsMutex.Unlock() 371 | if fake.invocations == nil { 372 | fake.invocations = map[string][][]interface{}{} 373 | } 374 | if fake.invocations[key] == nil { 375 | fake.invocations[key] = [][]interface{}{} 376 | } 377 | fake.invocations[key] = append(fake.invocations[key], args) 378 | } 379 | 380 | var _ rcon.RCON = new(FakeRCON) 381 | -------------------------------------------------------------------------------- /internal/rcon/rcon.go: -------------------------------------------------------------------------------- 1 | package rcon 2 | 3 | //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "io" 9 | "net" 10 | "strings" 11 | "sync" 12 | "sync/atomic" 13 | "syscall" 14 | 15 | "github.com/cenkalti/backoff/v4" 16 | "github.com/gorcon/rcon" 17 | "github.com/nekomeowww/factorio-rcon-api/v2/internal/configs" 18 | "github.com/nekomeowww/fo" 19 | "github.com/nekomeowww/xo/logger" 20 | "github.com/samber/lo" 21 | "go.uber.org/fx" 22 | "go.uber.org/zap" 23 | ) 24 | 25 | var ( 26 | ErrTimeout = errors.New("RCON connection is not established within deadline threshold") 27 | ) 28 | 29 | func Modules() fx.Option { 30 | return fx.Options( 31 | fx.Provide(NewRCON()), 32 | ) 33 | } 34 | 35 | type NewRCONParams struct { 36 | fx.In 37 | 38 | Lifecycle fx.Lifecycle 39 | Config *configs.Config 40 | Logger *logger.Logger 41 | } 42 | 43 | //counterfeiter:generate -o fake/rcon.go --fake-name FakeRCON . RCON//counterfeiter:generate -o fake/rcon.go --fake-name FakeRCON . RCON 44 | type RCON interface { 45 | Close() error 46 | Execute(ctx context.Context, command string) (string, error) 47 | LocalAddr() net.Addr 48 | RemoteAddr() net.Addr 49 | IsReady() bool 50 | } 51 | 52 | var _ RCON = (*RCONConn)(nil) 53 | 54 | type RCONConn struct { 55 | *rcon.Conn 56 | 57 | host string 58 | port string 59 | password string 60 | 61 | ready atomic.Bool 62 | reconnectChan chan struct{} 63 | readyChan chan struct{} 64 | 65 | mutex sync.RWMutex 66 | logger *logger.Logger 67 | ctx context.Context 68 | cancel context.CancelFunc 69 | } 70 | 71 | func NewRCON() func(NewRCONParams) (RCON, error) { 72 | return func(params NewRCONParams) (RCON, error) { 73 | connWrapper := &RCONConn{ 74 | Conn: nil, 75 | mutex: sync.RWMutex{}, 76 | logger: params.Logger, 77 | host: params.Config.Factorio.RCONHost, 78 | port: params.Config.Factorio.RCONPort, 79 | password: params.Config.Factorio.RCONPassword, 80 | reconnectChan: make(chan struct{}, 1), 81 | readyChan: make(chan struct{}, 1), 82 | } 83 | 84 | ctx, cancel := context.WithCancel(context.Background()) 85 | connWrapper.ctx = ctx 86 | connWrapper.cancel = cancel 87 | 88 | // Start the connection manager 89 | go connWrapper.connectionManager() 90 | 91 | // Trigger initial connection 92 | select { 93 | case connWrapper.reconnectChan <- struct{}{}: 94 | default: 95 | } 96 | 97 | params.Lifecycle.Append(fx.Hook{ 98 | OnStop: func(ctx context.Context) error { 99 | return fo.Invoke0(ctx, func() error { 100 | connWrapper.cancel() 101 | close(connWrapper.reconnectChan) 102 | close(connWrapper.readyChan) 103 | 104 | connWrapper.mutex.Lock() 105 | defer connWrapper.mutex.Unlock() 106 | 107 | if connWrapper.Conn != nil { 108 | return connWrapper.Conn.Close() //nolint:staticcheck 109 | } 110 | 111 | return nil 112 | }) 113 | }, 114 | }) 115 | 116 | return connWrapper, nil 117 | } 118 | } 119 | 120 | func (r *RCONConn) Execute(ctx context.Context, command string) (string, error) { 121 | return fo.Invoke(ctx, func() (string, error) { 122 | if !r.IsReady() { 123 | select { 124 | case <-ctx.Done(): 125 | return "", ctx.Err() 126 | case <-r.readyChan: 127 | } 128 | } 129 | 130 | r.mutex.RLock() 131 | conn := r.Conn 132 | r.mutex.RUnlock() 133 | 134 | if lo.IsNil(conn) { 135 | return r.Execute(ctx, command) 136 | } 137 | 138 | resp, err := conn.Execute(command) 139 | if err != nil { 140 | if !strings.Contains(err.Error(), "use of closed network connection") && 141 | !strings.Contains(err.Error(), "connection reset by peer") && 142 | !errors.Is(err, syscall.EPIPE) && 143 | !errors.Is(err, io.EOF) { 144 | return "", err 145 | } 146 | 147 | r.logger.Warn("RCON connection lost, reconnecting...") 148 | 149 | select { 150 | case r.reconnectChan <- struct{}{}: 151 | default: 152 | } 153 | 154 | return r.Execute(ctx, command) 155 | } 156 | 157 | return resp, nil 158 | }) 159 | } 160 | 161 | func (r *RCONConn) IsReady() bool { 162 | return r.ready.Load() 163 | } 164 | 165 | func (r *RCONConn) connectionManager() { 166 | backoffStrategy := backoff.NewExponentialBackOff() 167 | 168 | for { 169 | select { 170 | case <-r.ctx.Done(): 171 | return 172 | case <-r.reconnectChan: 173 | r.ready.Store(false) 174 | 175 | r.mutex.Lock() 176 | r.Conn = nil 177 | r.mutex.Unlock() 178 | 179 | conn, err := fo.Invoke(r.ctx, func() (*rcon.Conn, error) { 180 | var err error 181 | var rconConn *rcon.Conn 182 | 183 | err = backoff.Retry(func() error { 184 | rconConn, err = r.establishConnection(r.ctx) 185 | if err != nil { 186 | return err 187 | } 188 | 189 | return nil 190 | }, backoffStrategy) 191 | if err != nil { 192 | return nil, err 193 | } 194 | 195 | return rconConn, err 196 | }) 197 | 198 | if err != nil { 199 | r.logger.Error("failed to establish RCON connection after retries", zap.Error(err)) 200 | continue 201 | } 202 | 203 | r.mutex.Lock() 204 | r.Conn = conn 205 | r.mutex.Unlock() 206 | 207 | r.ready.Store(true) 208 | 209 | select { 210 | case r.readyChan <- struct{}{}: 211 | default: 212 | } 213 | } 214 | } 215 | } 216 | 217 | func (r *RCONConn) establishConnection(ctx context.Context) (*rcon.Conn, error) { 218 | return fo.Invoke(ctx, func() (*rcon.Conn, error) { 219 | r.mutex.Lock() 220 | defer r.mutex.Unlock() 221 | 222 | if r.Conn != nil { 223 | _ = r.Conn.Close() //nolint:staticcheck 224 | } 225 | 226 | conn, err := rcon.Dial(net.JoinHostPort(r.host, r.port), r.password) 227 | if err != nil { 228 | r.logger.Error("failed to connect to RCON", zap.Error(err)) 229 | return nil, err 230 | } 231 | 232 | // Test the connection 233 | _, err = conn.Execute("/help") 234 | if err != nil { 235 | r.logger.Error("failed to ping RCON", zap.Error(err)) 236 | return nil, err 237 | } 238 | 239 | r.logger.Info("RCON connection established successfully") 240 | 241 | return conn, nil 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /pkg/apierrors/apierrors.go: -------------------------------------------------------------------------------- 1 | package apierrors 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "buf.build/go/protovalidate" 8 | "github.com/labstack/echo/v4" 9 | "github.com/nekomeowww/factorio-rcon-api/v2/apis/jsonapi" 10 | "github.com/samber/lo" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | "google.golang.org/protobuf/runtime/protoiface" 14 | ) 15 | 16 | type Error struct { 17 | *jsonapi.ErrorObject 18 | 19 | caller *jsonapi.ErrorCaller 20 | 21 | grpcStatus uint32 22 | rawError error 23 | } 24 | 25 | func (e *Error) AsStatus() error { 26 | newStatus := status.New(codes.Code(e.grpcStatus), lo.Ternary(e.Detail == "", e.Title, e.Detail)) 27 | 28 | details := []protoiface.MessageV1{e.ErrorObject} 29 | if e.Caller() != nil { 30 | details = append(details, e.Caller()) 31 | } 32 | 33 | newStatus, _ = newStatus.WithDetails(details...) 34 | 35 | return newStatus.Err() 36 | } 37 | 38 | func (e *Error) AsResponse() *ErrResponse { 39 | return NewErrResponse().WithError(e) 40 | } 41 | 42 | func (e *Error) AsEchoResponse(c echo.Context) error { 43 | resp := e.AsResponse() 44 | return c.JSON(resp.HttpStatus(), resp) 45 | } 46 | 47 | func (e *Error) Caller() *jsonapi.ErrorCaller { 48 | return e.caller 49 | } 50 | 51 | func NewError[S ~int, GS ~uint32](status S, grpcStatus GS, code string) *Error { 52 | return &Error{ 53 | ErrorObject: &jsonapi.ErrorObject{ 54 | Id: code, 55 | Status: uint32(status), 56 | Code: code, 57 | }, 58 | grpcStatus: uint32(grpcStatus), 59 | } 60 | } 61 | 62 | func (e *Error) WithError(err error) *Error { 63 | e.rawError = err 64 | e.Detail = err.Error() 65 | 66 | return e 67 | } 68 | 69 | func (e *Error) WithValidationError(err error) *Error { 70 | validationErr, ok := err.(*protovalidate.ValidationError) 71 | if !ok { 72 | return e.WithDetail(err.Error()) 73 | } 74 | 75 | validationErrProto := validationErr.ToProto() 76 | if len(validationErrProto.Violations) == 0 { 77 | return e.WithDetail(err.Error()) 78 | } 79 | 80 | fieldPath := validationErrProto.Violations[0].Field.String() 81 | forKey := lo.FromPtrOr(validationErrProto.Violations[0].ForKey, false) 82 | message := lo.FromPtrOr(validationErrProto.Violations[0].Message, "") 83 | 84 | if forKey { 85 | e.WithDetail(message).WithSourceParameter(fieldPath) 86 | } else { 87 | e.WithDetail(message).WithSourcePointer(fieldPath) 88 | } 89 | 90 | return e 91 | } 92 | 93 | func (e *Error) WithCaller() *Error { 94 | pc, file, line, _ := runtime.Caller(1) 95 | 96 | e.caller = &jsonapi.ErrorCaller{ 97 | Function: runtime.FuncForPC(pc).Name(), 98 | File: file, 99 | Line: int32(line), 100 | } 101 | 102 | return e 103 | } 104 | 105 | func (e *Error) WithTitle(title string) *Error { 106 | e.Title = title 107 | 108 | return e 109 | } 110 | 111 | func (e *Error) WithDetail(detail string) *Error { 112 | e.Detail = detail 113 | 114 | return e 115 | } 116 | 117 | func (e *Error) WithDetailf(format string, args ...interface{}) *Error { 118 | e.Detail = fmt.Sprintf(format, args...) 119 | 120 | return e 121 | } 122 | 123 | func (e *Error) WithSourcePointer(pointer string) *Error { 124 | e.Source = &jsonapi.ErrorObjectSource{ 125 | Pointer: pointer, 126 | } 127 | 128 | return e 129 | } 130 | 131 | func (e *Error) WithSourceParameter(parameter string) *Error { 132 | e.Source = &jsonapi.ErrorObjectSource{ 133 | Parameter: parameter, 134 | } 135 | 136 | return e 137 | } 138 | 139 | func (e *Error) WithSourceHeader(header string) *Error { 140 | e.Source = &jsonapi.ErrorObjectSource{ 141 | Header: header, 142 | } 143 | 144 | return e 145 | } 146 | 147 | type ErrResponse struct { 148 | jsonapi.Response 149 | } 150 | 151 | func NewErrResponseFromErrorObjects(errs ...*jsonapi.ErrorObject) *ErrResponse { 152 | resp := NewErrResponse() 153 | 154 | for _, err := range errs { 155 | resp = resp.WithError(&Error{ 156 | ErrorObject: err, 157 | }) 158 | } 159 | 160 | return resp 161 | } 162 | 163 | func NewErrResponseFromErrorObject(err *jsonapi.ErrorObject) *ErrResponse { 164 | return NewErrResponse().WithError(&Error{ 165 | ErrorObject: err, 166 | }) 167 | } 168 | 169 | func NewErrResponse() *ErrResponse { 170 | return &ErrResponse{ 171 | Response: jsonapi.Response{ 172 | Errors: make([]*jsonapi.ErrorObject, 0), 173 | }, 174 | } 175 | } 176 | 177 | func (e *ErrResponse) WithError(err *Error) *ErrResponse { 178 | e.Errors = append(e.Errors, err.ErrorObject) 179 | 180 | return e 181 | } 182 | 183 | func (e *ErrResponse) WithValidationError(err error) *ErrResponse { 184 | validationErr, ok := err.(*protovalidate.ValidationError) 185 | if !ok { 186 | return e.WithError(NewErrInvalidArgument().WithError(err)) 187 | } 188 | 189 | validationErrProto := validationErr.ToProto() 190 | if len(validationErrProto.Violations) == 0 { 191 | return e.WithError(NewErrInvalidArgument().WithError(err)) 192 | } 193 | 194 | for _, violation := range validationErrProto.Violations { 195 | fieldPath := violation.Field.String() 196 | forKey := lo.FromPtrOr(violation.ForKey, false) 197 | message := lo.FromPtrOr(violation.Message, "") 198 | 199 | if forKey { 200 | e.WithError(NewErrInvalidArgument().WithDetail(message).WithSourceParameter(fieldPath)) 201 | } else { 202 | e.WithError(NewErrInvalidArgument().WithDetail(message).WithSourcePointer(fieldPath)) 203 | } 204 | } 205 | 206 | return e 207 | } 208 | 209 | func (e *ErrResponse) HttpStatus() int { 210 | if len(e.Errors) == 0 { 211 | return 200 212 | } 213 | 214 | return int(e.Errors[0].Status) 215 | } 216 | -------------------------------------------------------------------------------- /pkg/apierrors/errors.go: -------------------------------------------------------------------------------- 1 | package apierrors 2 | 3 | import ( 4 | "net/http" 5 | 6 | "google.golang.org/grpc/codes" 7 | ) 8 | 9 | func NewErrBadRequest() *Error { 10 | return NewError(http.StatusBadRequest, codes.InvalidArgument, "BAD_REQUEST"). 11 | WithTitle("Bad Request"). 12 | WithDetail("The request was invalid or cannot be served") 13 | } 14 | 15 | func NewErrInternal() *Error { 16 | return NewError(http.StatusInternalServerError, codes.Internal, "INTERNAL_SERVER_ERROR"). 17 | WithTitle("Internal Server Error"). 18 | WithDetail("An internal server error occurred") 19 | } 20 | 21 | func NewErrPermissionDenied() *Error { 22 | return NewError(http.StatusForbidden, codes.PermissionDenied, "PERMISSION_DENIED"). 23 | WithTitle("Permission Denied"). 24 | WithDetail("You do not have permission to access the requested resources") 25 | } 26 | 27 | func NewErrUnavailable() *Error { 28 | return NewError(http.StatusServiceUnavailable, codes.Unavailable, "UNAVAILABLE"). 29 | WithTitle("Service Unavailable"). 30 | WithDetail("The requested service is unavailable") 31 | } 32 | 33 | func NewErrInvalidArgument() *Error { 34 | return NewError(http.StatusBadRequest, codes.InvalidArgument, "INVALID_ARGUMENT"). 35 | WithTitle("Invalid Argument"). 36 | WithDetail("Invalid parameters, queries, body, or headers were sent, please check the request") 37 | } 38 | 39 | func NewErrUnauthorized() *Error { 40 | return NewError(http.StatusUnauthorized, codes.Unauthenticated, "UNAUTHORIZED"). 41 | WithTitle("Unauthorized"). 42 | WithDetail("The requested resources require authentication") 43 | } 44 | 45 | func NewErrNotFound() *Error { 46 | return NewError(http.StatusNotFound, codes.NotFound, "NOT_FOUND"). 47 | WithTitle("Not Found"). 48 | WithDetail("The requested resources were not found") 49 | } 50 | 51 | func NewErrPaymentRequired() *Error { 52 | return NewError(http.StatusPaymentRequired, codes.FailedPrecondition, "PAYMENT_REQUIRED"). 53 | WithTitle("Payment Required"). 54 | WithDetail("The requested resources require payment") 55 | } 56 | 57 | func NewErrQuotaExceeded() *Error { 58 | return NewError(http.StatusTooManyRequests, codes.ResourceExhausted, "QUOTA_EXCEEDED"). 59 | WithTitle("Quota Exceeded"). 60 | WithDetail("The request quota has been exceeded") 61 | } 62 | 63 | func NewErrForbidden() *Error { 64 | return NewError(http.StatusForbidden, codes.PermissionDenied, "FORBIDDEN"). 65 | WithTitle("Forbidden"). 66 | WithDetail("You do not have permission to access the requested resources") 67 | } 68 | 69 | func NewErrTimeout() *Error { 70 | return NewError(http.StatusRequestTimeout, codes.DeadlineExceeded, "TIMEOUT"). 71 | WithTitle("Request Timeout"). 72 | WithDetail("The request has timed out") 73 | } 74 | -------------------------------------------------------------------------------- /pkg/grpc/gateway.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 8 | "github.com/nekomeowww/xo/logger" 9 | "google.golang.org/grpc" 10 | ) 11 | 12 | type gatewayOptions struct { 13 | serverMuxOptions []runtime.ServeMuxOption 14 | handlers []func(ctx context.Context, serveMux *runtime.ServeMux, clientConn *grpc.ClientConn) error 15 | } 16 | 17 | type GatewayCallOption func(*gatewayOptions) 18 | 19 | func WithServerMuxOptions(opts ...runtime.ServeMuxOption) GatewayCallOption { 20 | return func(o *gatewayOptions) { 21 | o.serverMuxOptions = append(o.serverMuxOptions, opts...) 22 | } 23 | } 24 | 25 | func WithHandlers(handlers ...func(ctx context.Context, serveMux *runtime.ServeMux, clientConn *grpc.ClientConn) error) GatewayCallOption { 26 | return func(o *gatewayOptions) { 27 | o.handlers = append(o.handlers, handlers...) 28 | } 29 | } 30 | 31 | func NewGateway( 32 | ctx context.Context, 33 | conn *grpc.ClientConn, 34 | logger *logger.Logger, 35 | callOpts ...GatewayCallOption, 36 | ) (http.Handler, error) { 37 | opts := &gatewayOptions{} 38 | 39 | for _, f := range callOpts { 40 | f(opts) 41 | } 42 | 43 | mux := runtime.NewServeMux(opts.serverMuxOptions...) 44 | 45 | for _, f := range opts.handlers { 46 | if err := f(ctx, mux, conn); err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | return mux, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/grpc/register.go: -------------------------------------------------------------------------------- 1 | package grpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 7 | "github.com/labstack/echo/v4" 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/reflection" 10 | ) 11 | 12 | type HTTPHandler = func(ctx context.Context, serveMux *runtime.ServeMux, clientConn *grpc.ClientConn) error 13 | type GRPCServiceRegister func(s reflection.GRPCServer) 14 | 15 | type Register struct { 16 | HTTPHandlers []HTTPHandler 17 | GrpcServices []GRPCServiceRegister 18 | EchoHandlers map[string]map[string]echo.HandlerFunc 19 | } 20 | 21 | func NewRegister() *Register { 22 | return &Register{ 23 | HTTPHandlers: make([]HTTPHandler, 0), 24 | GrpcServices: make([]GRPCServiceRegister, 0), 25 | EchoHandlers: make(map[string]map[string]echo.HandlerFunc), 26 | } 27 | } 28 | 29 | func (r *Register) RegisterHTTPHandler(handler HTTPHandler) { 30 | r.HTTPHandlers = append(r.HTTPHandlers, handler) 31 | } 32 | 33 | func (r *Register) RegisterHTTPHandlers(handlers []HTTPHandler) { 34 | r.HTTPHandlers = append(r.HTTPHandlers, handlers...) 35 | } 36 | 37 | func (r *Register) RegisterGrpcService(serviceRegister GRPCServiceRegister) { 38 | r.GrpcServices = append(r.GrpcServices, serviceRegister) 39 | } 40 | 41 | func (r *Register) RegisterEchoHandler(path string, method string, handler echo.HandlerFunc) { 42 | if _, ok := r.EchoHandlers[path]; !ok { 43 | r.EchoHandlers[path] = make(map[string]echo.HandlerFunc) 44 | } 45 | 46 | r.EchoHandlers[path][method] = handler 47 | } 48 | -------------------------------------------------------------------------------- /pkg/http/trace.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.opentelemetry.io/otel" 7 | "go.opentelemetry.io/otel/propagation" 8 | ) 9 | 10 | type TraceparentWrapper struct { 11 | next http.Handler 12 | props propagation.TextMapPropagator 13 | } 14 | 15 | func NewTraceparentWrapper(next http.Handler) *TraceparentWrapper { 16 | return &TraceparentWrapper{ 17 | next: next, 18 | props: otel.GetTextMapPropagator(), 19 | } 20 | } 21 | 22 | func (t *TraceparentWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { 23 | t.props.Inject(r.Context(), propagation.HeaderCarrier(w.Header())) 24 | t.next.ServeHTTP(w, r) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/utils/factorio.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/golang-module/carbon" 9 | v1 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v1" 10 | v2 "github.com/nekomeowww/factorio-rcon-api/v2/apis/factorioapi/v2" 11 | "github.com/nekomeowww/factorio-rcon-api/v2/pkg/apierrors" 12 | "github.com/samber/lo" 13 | ) 14 | 15 | func StringListToPlayers(list string) ([]*v1.Player, error) { 16 | // output: 17 | // NekoMeow (online)\n 18 | // NekoMeow2 (offline)\n 19 | split := strings.Split(strings.TrimSuffix(list, "\n"), "\n") 20 | players := make([]*v1.Player, 0, len(split)) 21 | 22 | for _, line := range split { 23 | line = strings.TrimSpace(line) 24 | 25 | parts := strings.Split(line, " ") 26 | if len(parts) > 2 { //nolint:mnd 27 | return nil, apierrors.NewErrBadRequest().WithDetailf("failed to parse admins: %s due to parts not equals 2", line).AsStatus() 28 | } 29 | 30 | player := &v1.Player{ 31 | Username: parts[0], 32 | } 33 | if len(parts) == 2 { //nolint:mnd 34 | player.Online = parts[1] == "(online)" 35 | } 36 | 37 | players = append(players, player) 38 | } 39 | 40 | return players, nil 41 | } 42 | 43 | func PrefixedStringCommaSeparatedListToPlayers(list string, prefix string) ([]*v1.Player, error) { 44 | // output: 45 | // SomePrefix: NekoMeow, NekoMeow2\n 46 | withoutPrefix := strings.TrimPrefix(list, prefix+": ") 47 | split := strings.Split(withoutPrefix, ",") 48 | split = lo.Map(split, func(item string, _ int) string { return strings.TrimSpace(item) }) 49 | 50 | return lo.Map(split, func(item string, _ int) *v1.Player { 51 | return &v1.Player{Username: item} 52 | }), nil 53 | } 54 | 55 | func MapV1PlayerToV2Player(v1Player *v1.Player) *v2.Player { 56 | return &v2.Player{ 57 | Username: v1Player.GetUsername(), 58 | Online: v1Player.GetOnline(), 59 | } 60 | } 61 | 62 | func MapV1PlayersToV2Players(v1Players []*v1.Player) []*v2.Player { 63 | return lo.Map(v1Players, func(item *v1.Player, _ int) *v2.Player { return MapV1PlayerToV2Player(item) }) 64 | } 65 | 66 | func ParseDuration(input string) (time.Duration, error) { 67 | // Split the input string into parts 68 | parts := strings.Fields(input) 69 | 70 | // Initialize the total duration 71 | var totalDuration time.Duration 72 | 73 | // Iterate over the parts and parse the time values 74 | for i := range parts { 75 | switch parts[i] { 76 | case "days": 77 | if i > 0 { 78 | days, err := strconv.ParseInt(parts[i-1], 10, 64) 79 | if err != nil { 80 | return 0, err 81 | } 82 | 83 | totalDuration += time.Duration(days) * carbon.HoursPerDay 84 | } 85 | case "hours": 86 | if i > 0 { 87 | hours, err := time.ParseDuration(parts[i-1] + "h") 88 | if err != nil { 89 | return 0, err 90 | } 91 | 92 | totalDuration += hours 93 | } 94 | case "minutes": 95 | if i > 0 { 96 | minutes, err := time.ParseDuration(parts[i-1] + "m") 97 | if err != nil { 98 | return 0, err 99 | } 100 | 101 | totalDuration += minutes 102 | } 103 | case "seconds": 104 | if i > 0 { 105 | seconds, err := time.ParseDuration(parts[i-1] + "s") 106 | if err != nil { 107 | return 0, err 108 | } 109 | 110 | totalDuration += seconds 111 | } 112 | } 113 | } 114 | 115 | return totalDuration, nil 116 | } 117 | 118 | func ParseWhitelistedPlayers(input string) []string { 119 | // Replace " and " with a comma to unify the delimiters 120 | input = strings.ReplaceAll(input, " and ", ", ") 121 | 122 | // Split the players by commas and trim spaces 123 | players := strings.Split(input, ", ") 124 | for i := range players { 125 | players[i] = strings.TrimSpace(players[i]) 126 | } 127 | 128 | return players 129 | } 130 | -------------------------------------------------------------------------------- /pkg/utils/factorio_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestStringListToPlayers(t *testing.T) { 11 | players, err := StringListToPlayers(" NekoMeow (online)\n NekoMeow2\n") 12 | require.NoError(t, err) 13 | require.Len(t, players, 2) 14 | } 15 | 16 | func TestParseWhitelistedPlayers(t *testing.T) { 17 | type testCase struct { 18 | input string 19 | expected []string 20 | } 21 | 22 | testCases := []testCase{ 23 | {input: "NekoMeow", expected: []string{"NekoMeow"}}, 24 | {input: "LittleSound and NekoMeow", expected: []string{"LittleSound", "NekoMeow"}}, 25 | {input: "LemonNeko, LittleSound and NekoMeow", expected: []string{"LemonNeko", "LittleSound", "NekoMeow"}}, 26 | } 27 | 28 | for index, tc := range testCases { 29 | t.Run(strconv.Itoa(index), func(t *testing.T) { 30 | players := ParseWhitelistedPlayers(tc.input) 31 | require.Equal(t, tc.expected, players) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/maxbrunsfeld/counterfeiter/v6" 7 | ) 8 | 9 | // This file imports packages that are used when running go generate, or used 10 | // during the development process but not otherwise depended on by built code. 11 | --------------------------------------------------------------------------------